slicetest 0.6.1 → 0.7.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 +109 -14
- package/dist/cli.js +46 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +10 -0
- package/dist/connection.d.ts +5 -0
- package/dist/connection.js +31 -0
- package/dist/diagram.d.ts +13 -0
- package/dist/diagram.js +89 -0
- package/dist/global-setup.js +45 -4
- package/dist/graphql.d.ts +34 -0
- package/dist/graphql.js +56 -0
- package/dist/har.d.ts +49 -0
- package/dist/har.js +107 -0
- package/dist/http.d.ts +10 -0
- package/dist/http.js +15 -1
- package/dist/index.d.ts +2 -1
- package/dist/init.js +184 -15
- package/dist/matchers.d.ts +9 -0
- package/dist/matchers.js +47 -1
- package/dist/openapi.d.ts +21 -0
- package/dist/openapi.js +32 -1
- package/dist/provided.d.ts +1 -0
- package/dist/recording.d.ts +1 -1
- package/dist/recording.js +2 -2
- package/dist/runtime.d.ts +12 -5
- package/dist/runtime.js +77 -22
- package/dist/scenario.js +3 -0
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +73 -0
- package/dist/stub.d.ts +41 -0
- package/dist/stub.js +187 -3
- package/dist/timeline.d.ts +9 -0
- package/dist/timeline.js +6 -0
- package/dist/yaml-runtime.js +67 -20
- package/dist/yaml.d.ts +40 -5
- package/dist/yaml.js +102 -15
- package/package.json +2 -1
- package/schema/scenario.schema.json +279 -12
package/README.md
CHANGED
|
@@ -38,23 +38,25 @@ 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
|
|
41
|
+
`init` recognises Node (`npm start`, or Bun, pnpm or Yarn from the lockfile), Deno, Phoenix, Django, FastAPI, Flask, Rails, Laravel, Symfony, plain PHP, Spring Boot, ASP.NET Core, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Laravel, Doctrine, EF Core, Ecto, Drizzle, Knex and plain SQL migrations; and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. A mail catcher there (Mailpit, MailHog, MailDev, smtp4dev, …) or a mail library in the dependencies turns on [`mail`](#mail-catch-what-the-app-sends). SQLite is picked up from Prisma's provider, Rails' `database.yml`, Django's settings or a SQLite driver, with `DATABASE_URL` in the form the framework reads (`file:…`, `sqlite3:…`). And third-party API URLs in `.env.example` (`STRIPE_API_BASE=https://api.stripe.com`) become stubs [recorded from that service](#recording-a-real-service), with the variable pointed at the stub, while local addresses, databases and your own URLs are left alone. Token issuer settings there (`OIDC_ISSUER`, `AUTH0_DOMAIN`, `JWKS_URL`, `JWT_AUDIENCE`, …) turn on [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) and point at slicetest's issuer instead of becoming stubs. It lists every guess as a comment in the config so you know what to check.
|
|
42
42
|
|
|
43
43
|
## What you get that's hard to find elsewhere
|
|
44
44
|
|
|
45
45
|
- **One scenario, three boundaries.** Assert on the HTTP response, the rows in the real database and the calls to third-party APIs in the same test, in any language the app is written in.
|
|
46
46
|
- **`db.changes()`**: a diff of every row the scenario inserted, updated or deleted. `toEqual` on it catches writes you didn't expect.
|
|
47
|
-
- **Stubs that can't lie.** Give a stub the provider's OpenAPI spec, and a canned reply the real service would never send fails the test.
|
|
47
|
+
- **Stubs that can't lie.** Give a stub the provider's OpenAPI spec, and a canned reply the real service would never send fails the test. The run also lists which of the provider's operations the app depends on, flagging deprecated ones.
|
|
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
|
-
- **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.
|
|
49
|
+
- **Record the real service once, replay forever.** Point a stub at the real API with `SLICETEST_RECORD=1`, or import a HAR file saved from the browser, 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.
|
|
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 and a sequence diagram of each failed scenario.
|
|
53
|
+
- **Diagrams that can't go stale.** `--diagrams docs/flows` writes a Mermaid sequence diagram of every scenario (app, stubs, mail, database), regenerated from what really happened on each run.
|
|
53
54
|
- **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
55
|
- **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
56
|
- **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
57
|
- **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
58
|
- **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.
|
|
59
|
+
- **GraphQL on both sides.** Stubs answer by operation name and variables rather than by path, and `toHaveGraphQLData()` fails on the `errors` a GraphQL server returns with status 200.
|
|
58
60
|
- **Forms as a browser sends them.** `http.submit()` presses a button on a server-rendered page, hidden fields included, so CSRF tokens and Next.js server actions work without knowing their internals.
|
|
59
61
|
- **Hard-coded APIs, stubbed anyway.** `hosts: [api.github.com]` catches calls to URLs written in the code or built into a framework, over HTTPS, from Node, Python, Go, Ruby or the JVM, with no change to the app. Redirects to those hosts are followed to the stub, so OAuth logins run end to end.
|
|
60
62
|
- **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.
|
|
@@ -142,7 +144,8 @@ slicetest prints what happened during that scenario, next to Vitest's own error:
|
|
|
142
144
|
--- slicetest ---
|
|
143
145
|
stub calls with no matching route:
|
|
144
146
|
mail: POST /send
|
|
145
|
-
|
|
147
|
+
closest route POST /send: json.to: expected "a@example.com", got "b@example.com"
|
|
148
|
+
registered on mail: POST /send + json conditions
|
|
146
149
|
|
|
147
150
|
requests to the app:
|
|
148
151
|
POST /signup → 500 (14ms) {"error":"internal"}
|
|
@@ -158,7 +161,7 @@ TypeError: Cannot read properties of undefined (reading 'email')
|
|
|
158
161
|
-----------------
|
|
159
162
|
```
|
|
160
163
|
|
|
161
|
-
Only this scenario's app output is shown, not the whole log. The database section is a diff against the state right after the reset and seed, so you see what the app actually wrote. Requests that never got a response (for example because the app crashed) appear as `failed`.
|
|
164
|
+
For a call no route answered, slicetest names the route it came closest to and the first thing that differed: the method, a path that is off by a trailing slash, letter case or a base-URL prefix (`/api/v1/charges` against `/v1/charges`), a missing header, or the JSON field and value (`json.items.0.sku: expected "a", got "b"`), the GraphQL operation or variables, or a `once()` route that was already used. Only this scenario's app output is shown, not the whole log. The database section is a diff against the state right after the reset and seed, so you see what the app actually wrote. Requests that never got a response (for example because the app crashed) appear as `failed`.
|
|
162
165
|
|
|
163
166
|
## API
|
|
164
167
|
|
|
@@ -174,6 +177,7 @@ await http.get("/polls", { query: { page: 2 }, headers: { accept: "text/html" }
|
|
|
174
177
|
await http.post("/login", http.form({ user: "a", pass: "b" })); // urlencoded; FormData, Blob and bytes also work
|
|
175
178
|
await http.get("/old-path", { follow: true }); // redirects are NOT followed by default
|
|
176
179
|
await http.submit(await http.get("/signup"), { button: "Sign up", fields: { email: "a@b.test" } }); // a form, as a browser sends it
|
|
180
|
+
await http.graphql("query Poll($id: ID!) { poll(id: $id) { title } }", { id: 1 }); // POST /graphql ({ path } for another)
|
|
177
181
|
|
|
178
182
|
const admin = http.with({ headers: { authorization: `Bearer ${token}` } }); // shares cookies with http
|
|
179
183
|
http.cookies.get("session"); // cookies persist within a scenario
|
|
@@ -280,10 +284,11 @@ stub("pay").on("GET", "/status").replySequence([{ status: 503 }, { status: 200 }
|
|
|
280
284
|
stub("pay").on("POST", "/charge").delay(5_000).reply(200); // exercise the app's timeouts
|
|
281
285
|
stub("pay").on("POST", "/charge").networkError(); // drop the connection
|
|
282
286
|
|
|
287
|
+
stub("slack").on("POST", "/hook").optional().reply(200); // may go uncalled, even with strictStubs
|
|
283
288
|
stub("slack").calls("POST", "/hook"); // recorded calls: method, path, params, query, headers, body, json
|
|
284
289
|
```
|
|
285
290
|
|
|
286
|
-
Later routes win. `path` may also be a RegExp, and `method` may be `*`. Unanswered calls get a `501` and fail the scenario.
|
|
291
|
+
Later routes win. `path` may also be a RegExp, and `method` may be `*`. Unanswered calls get a `501` and fail the scenario, with the closest route and why it didn't match (`stub.explain(call)`).
|
|
287
292
|
|
|
288
293
|
### OpenAPI contracts — for your app and for the services you stub
|
|
289
294
|
|
|
@@ -308,6 +313,15 @@ slicetest: traffic doesn't match the OpenAPI spec:
|
|
|
308
313
|
stub mail reply (the real service wouldn't answer this way): POST /mail/send responded 200, which specs/mail.yaml doesn't document (documented: 202)
|
|
309
314
|
```
|
|
310
315
|
|
|
316
|
+
At the end of the run, each stub with a spec reports which of the provider's operations the app called across all scenarios: its footprint on that API, for planning an upgrade or a switch of provider. Operations the provider marks `deprecated` are flagged, and on GitHub Actions they become a warning annotation and the list goes to the job summary:
|
|
317
|
+
|
|
318
|
+
```
|
|
319
|
+
slicetest: the app used 3 of 587 operations of stripe (specs/stripe.yaml), 1 deprecated
|
|
320
|
+
POST /v1/charges ⚠ deprecated
|
|
321
|
+
POST /v1/payment_intents
|
|
322
|
+
GET /v1/customers/{customer}
|
|
323
|
+
```
|
|
324
|
+
|
|
311
325
|
#### `autoReply`: stubs generated from the provider's spec
|
|
312
326
|
|
|
313
327
|
Add `autoReply: true` to a stub with a spec, and calls that no route matches are answered with the provider's documented example, or with values built from the schema (formats such as `email` and `date-time`, enums, `minimum`, `allOf` are respected). Register routes only for what a scenario cares about; a registered route always wins.
|
|
@@ -351,6 +365,23 @@ stub("anthropic").on("POST", "/v1/messages").reply(sse([
|
|
|
351
365
|
|
|
352
366
|
In YAML, `reply: { sse: [{ event: message_start, data: { ... } }, ...] }` instead of `body`.
|
|
353
367
|
|
|
368
|
+
#### GraphQL: stubs that answer an operation
|
|
369
|
+
|
|
370
|
+
GraphQL APIs (GitHub, Shopify, Linear, Contentful, your own) take every call at one path, so `on("POST", "/graphql")` can't tell them apart. `stub.graphql()` matches the operation instead, whatever the path, for POSTs with `{ query, variables, operationName }` and GETs with those as query parameters. Without `operationName`, the name in the document counts (`query Viewer { ... }`):
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
stub("github").graphql("Viewer").data({ viewer: { login: "octocat" } });
|
|
374
|
+
stub("github").graphql("CreateIssue", { variables: { title: "Bug" } }).data((call) => ({ createIssue: { issue: { number: 1, title: call.graphql.variables.title } } }));
|
|
375
|
+
stub("github").graphql("CreateIssue").once().errors(["rate limited"]); // { errors: [{ message }] } with status 200, as servers do
|
|
376
|
+
|
|
377
|
+
expect(stub("github")).toHaveReceivedGraphQL("CreateIssue", { title: "Bug" }); // variables as a subset
|
|
378
|
+
expect(await http.graphql(REPORT_BUG, { title: "Bug" })).toHaveGraphQLData({ reportBug: { number: 1 } });
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
`toHaveGraphQLData()` fails on a response with `errors` and prints them, which `toHaveStatus(200)` can't, since GraphQL servers report errors with 200. Unanswered operations fail the scenario named as `GraphQL mutation CreateIssue`. `call.graphql` holds `{ operation, type, query, variables }` for every GraphQL call a stub receives.
|
|
382
|
+
|
|
383
|
+
In YAML, a stub step takes `graphql: CreateIssue` instead of `on`, `when: { variables }`, and `reply: { data, errors }`; a request takes `graphql: { query, variables }` instead of `json` and fails on `errors` unless `expect.json` names them; a received step takes `graphql:` instead of `call`.
|
|
384
|
+
|
|
354
385
|
#### Chaos: faults the app must survive
|
|
355
386
|
|
|
356
387
|
`chaos()` makes a stub misbehave for the rest of the scenario, to test retries, timeouts and fallbacks against the app's real HTTP client:
|
|
@@ -430,6 +461,17 @@ SLICETEST_RECORD=github npx vitest # or SLICETEST_RECORD=1 for every stub wi
|
|
|
430
461
|
|
|
431
462
|
Calls no route matches are forwarded to `upstream` (under its path prefix, headers included) and the answers are written to `recordings/github.yaml` (`recordings:` changes the path). Later runs replay them without touching the network. A request is identified by method, path, query and body (JSON key order doesn't matter); identical requests replay their recordings in the order they were made. Only `content-type`, `location`, `retry-after`, `link` and `etag` response headers are kept, and request headers are never stored, so tokens stay out of the file; bodies are stored as sent, so review the file before committing it.
|
|
432
463
|
|
|
464
|
+
#### From a HAR file: `npx slicetest import`
|
|
465
|
+
|
|
466
|
+
No credentials at hand, or the call happens in a flow that's easier to click through? Save the network traffic as HAR (the browser's network panel: "Save all as HAR"; Charles, mitmproxy, Proxyman and Postman export it too) and import it:
|
|
467
|
+
|
|
468
|
+
```sh
|
|
469
|
+
npx slicetest import session.har # every stub with an upstream that the HAR has requests for
|
|
470
|
+
npx slicetest import session.har --stub stripe --upstream https://api.stripe.com
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Requests under a stub's `upstream` become entries of its recordings file, in the same format and with the same filtering as recording: only the five response headers above, no request headers, the path relative to the upstream's. Preflights, aborted requests and binary responses are skipped, and the hosts it didn't import are listed. Entries already in the file aren't added twice.
|
|
474
|
+
|
|
433
475
|
Precedence is: registered route, then recording, then `autoReply`, then a 501 that says how to record the call. Replayed calls have `call.fallback === true`, and are checked against the provider's spec when the stub has one. To refresh recordings, delete the file (or the entries) and record again.
|
|
434
476
|
|
|
435
477
|
### Matchers
|
|
@@ -442,11 +484,14 @@ expect(responses).toHaveStatuses({ 201: 1, 409: 9 }); // an array, e.
|
|
|
442
484
|
expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "hi" } });
|
|
443
485
|
expect(stub("slack")).toHaveReceivedTimes(1, "POST", "/hook");
|
|
444
486
|
expect(stub("mail")).not.toHaveReceived("POST", "/send");
|
|
487
|
+
expect(stub("github")).toHaveReceivedGraphQL("CreateIssue", { title: "Bug" });
|
|
488
|
+
expect(await http.graphql(QUERY)).toHaveGraphQLData({ poll: { title: "x" } }); // no errors, data as a subset
|
|
445
489
|
await expect(db).toHaveRow("polls", { title: "x" }); // at least one row
|
|
446
490
|
await expect(db).toHaveRow("votes", { poll_id: 1 }, 3); // exactly three
|
|
491
|
+
expect(res).toMatchSchema("openapi.yaml#/components/schemas/Poll"); // a response's JSON, or any value; inline schemas too
|
|
447
492
|
```
|
|
448
493
|
|
|
449
|
-
Failure messages list the calls the stub actually received, or the first rows of the table.
|
|
494
|
+
Failure messages list the calls the stub actually received, or the first rows of the table. `toMatchSchema` takes a JSON Schema object or a file with an optional pointer (relative to the working directory; JSON or YAML, OpenAPI 3.0 `nullable` understood, `$ref`s resolved within the file), and lists every mismatch by its JSON path: no OpenAPI setup is needed to check one response's shape.
|
|
450
495
|
|
|
451
496
|
### Services: workers and other processes
|
|
452
497
|
|
|
@@ -607,10 +652,30 @@ Dates (`Date` values and ISO strings) become `[date]` and UUIDs `[uuid]`. Mask m
|
|
|
607
652
|
|
|
608
653
|
The example apps share one snapshot file: the Node and the Python implementation must produce the same trace, byte for byte.
|
|
609
654
|
|
|
655
|
+
### Sequence diagrams of every scenario
|
|
656
|
+
|
|
657
|
+
`await diagram()` returns the scenario so far as a [Mermaid](https://mermaid.js.org/) sequence diagram: each request to the app, the stub calls the app made while answering it (GraphQL ones by operation), their replies, the mail it sent and the tables it changed:
|
|
658
|
+
|
|
659
|
+
```mermaid
|
|
660
|
+
sequenceDiagram
|
|
661
|
+
participant test as scenario
|
|
662
|
+
participant app
|
|
663
|
+
participant s_slack as slack (stub)
|
|
664
|
+
participant db as database
|
|
665
|
+
test->>+app: POST /polls
|
|
666
|
+
app->>+s_slack: POST /hook
|
|
667
|
+
s_slack-->>-app: 200
|
|
668
|
+
app-->>-test: 201 id: 1
|
|
669
|
+
Note over app,db: polls +1
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
- **Living documentation.** `npx slicetest --diagrams docs/flows` (or `SLICETEST_DIAGRAMS=docs/flows` with Vitest) writes one Markdown page per scenario file, with a diagram for each scenario. Commit them, and a pull request that changes how the app talks to the outside world shows it as a diagram diff.
|
|
673
|
+
- **Failures on GitHub Actions.** A failing scenario's diagram goes to the job summary, collapsed under its name, next to the error.
|
|
674
|
+
|
|
610
675
|
### Scenarios
|
|
611
676
|
|
|
612
677
|
```ts
|
|
613
|
-
scenario("name", async ({ http, db, stub, app, service, container, trace }) => { ... }, timeoutMs?);
|
|
678
|
+
scenario("name", async ({ http, db, stub, app, service, container, trace, diagram }) => { ... }, timeoutMs?);
|
|
614
679
|
scenario.only / scenario.skip / scenario.todo
|
|
615
680
|
scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
|
|
616
681
|
"voting $choice returns $status",
|
|
@@ -627,13 +692,13 @@ Scenarios in one file share an app and a database, so they always run one at a t
|
|
|
627
692
|
| `app.command` | (required) | Shell command. May use `{{app.port}}` and the other placeholders. |
|
|
628
693
|
| `app.build` | none | Shell command run once per run before the app starts, while the database starts, e.g. `npm run build` for `next start`. It gets `app.env`'s literal values (Next.js inlines `NEXT_PUBLIC_*` at build time), not those with placeholders. Not repeated in watch mode. Services take `build` too. |
|
|
629
694
|
| `db` | Postgres in a container | Database options (below), or `false` for an app without a database: nothing is started, `{{db.*}}` aren't set and `db.*` in scenarios explains that it's off. `slicetest init` writes `false` when it finds no migrations, no database service and no database library. |
|
|
630
|
-
| `app.env` | `{ PORT, DATABASE_URL }` | Values may use `{{app.port}}`, `{{db.url}}`, `{{stub.<name>}}`. For apps that don't take one URL: `{{db.jdbcUrl}}` (`jdbc:postgresql://…`), `{{db.host}}`, `{{db.port}}`, `{{db.name}}`, `{{db.user}}`, `{{db.password}}`. The rest of `process.env` is inherited. |
|
|
695
|
+
| `app.env` | `{ PORT, DATABASE_URL }` | Values may use `{{app.port}}`, `{{db.url}}`, `{{stub.<name>}}`. For apps that don't take one URL: `{{db.jdbcUrl}}` (`jdbc:postgresql://…`), `{{db.adoNet}}` (an ADO.NET connection string, `Host=…;Port=…;Database=…;Username=…;Password=…`, for .NET's `ConnectionStrings__Default`), `{{db.host}}`, `{{db.port}}`, `{{db.name}}`, `{{db.user}}`, `{{db.password}}`. The rest of `process.env` is inherited. |
|
|
631
696
|
| `app.cwd` | vitest root | |
|
|
632
697
|
| `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
|
|
633
698
|
| `app.readyTimeout` | `30000` | |
|
|
634
699
|
| `app.scope` | `"file"` | `"worker"`: start the app (and stubs, services) once per Vitest worker and keep it for all of that worker's test files, for apps that start slowly. Sets Vitest's `isolate: false`. |
|
|
635
700
|
| `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
|
|
636
|
-
| `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs? }
|
|
701
|
+
| `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs?, env? }`. The command gets `DATABASE_URL`, and both it and `env` may use the `{{db.*}}` placeholders, for tools that read other variables: `{ command: "php artisan migrate --force", env: { DB_HOST: "{{db.host}}", DB_DATABASE: "{{db.name}}" } }`, `{ command: "dotnet ef database update --connection \"{{db.adoNet}}\"" }`. |
|
|
637
702
|
| `db.seed` | none | SQL file re-run after every reset. |
|
|
638
703
|
| `db.schemas` | `["public"]` | Schemas whose tables are reset. |
|
|
639
704
|
| `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
|
|
@@ -647,6 +712,7 @@ Scenarios in one file share an app and a database, so they always run one at a t
|
|
|
647
712
|
| `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). |
|
|
648
713
|
| `services` | `{}` | Other processes: `{ name: { command, env?, cwd?, ready?, readyTimeout? } }`. Without `ready` a service is not waited for. |
|
|
649
714
|
| `offline` | `false` | Refuse the app's HTTP(S) calls to hosts no stub intercepts, and fail the scenario naming them. |
|
|
715
|
+
| `strictStubs` | `false` | Fail a scenario that registered a stub route the app never called, so a test can't pass without reaching the code it set up for. Exempt a route with `.optional()` (YAML `optional: true`). Unused routes are listed in the failure output either way. |
|
|
650
716
|
| `workers` | Vitest's default | Most Vitest workers (`maxWorkers`). Each has its own app and database. |
|
|
651
717
|
| `stubs` | `[]` | Names of stubbed services, or `{ name, openapi?, autoReply?, upstream?, recordings?, hosts? }`: check calls against the provider's spec, answer from it, [replay recordings](#recording-a-real-service) of the real service, or answer for [hard-coded hosts](#hard-coded-hosts-hosts). |
|
|
652
718
|
| `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`, or `{ fromApp: "/v3/api-docs" }` for a spec the running app serves (springdoc, FastAPI's `/openapi.json`, NestJS). Every response must match it; the run ends with a coverage report. |
|
|
@@ -700,9 +766,9 @@ scenarios:
|
|
|
700
766
|
|
|
701
767
|
| Step | Keys |
|
|
702
768
|
|---|---|
|
|
703
|
-
| `stub: <name>` | `on: METHOD /path` (`:params` allowed)
|
|
769
|
+
| `stub: <name>` | `on: METHOD /path` (`:params` allowed) or `graphql: <operation>`, `when: { query, headers, json, body, variables }`, one of `reply: { status, headers, body }` (`{ data, errors }` for GraphQL) / `sequence: [...]` / `networkError: true`, plus `times`, `delay`. Replies may echo the call: `{{call.params.id}}`, `{{call.json.name}}`, `{{call.variables.id}}`. |
|
|
704
770
|
| `submit: <button>` | `form`, `fields`, `headers`, `follow`, `expect: { status, headers, json, text }`, `capture`. Submits a form of the page the last request returned, like `http.submit()`; `submit: true` presses the form's only button. |
|
|
705
|
-
| `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. |
|
|
771
|
+
| `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `body` / `graphql: { query, variables, operationName }`, `expect.schema` (a JSON Schema, or `../openapi.yaml#/components/schemas/Poll` relative to the file), `follow`, `expect: { status, headers, json, text }`, `capture`. `concurrency: n` sends it `n` times at once; `expect` then applies to each response, and `expect.statuses: { 201: 1, 409: 9 }` counts them. |
|
|
706
772
|
| `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
|
|
707
773
|
| `request` with `auth` | `auth: true` or the claims: sends a bearer token from the `auth` issuer |
|
|
708
774
|
| `request` with `webhook` | `{ provider, secret, event, stale, invalidSignature }`: signs the body like that provider's deliveries |
|
|
@@ -710,11 +776,12 @@ scenarios:
|
|
|
710
776
|
| `make: <table>` | `rows` (a mapping, or a list for several rows), `count`, `capture` (from `row` / `rows`) — like `db.make()` |
|
|
711
777
|
| `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
|
|
712
778
|
| `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
|
|
713
|
-
| `received: <stub>` | `call: METHOD /path
|
|
779
|
+
| `received: <stub>` | `call: METHOD /path` or `graphql: <operation>`, `when`, `times` (exact; default at least once) |
|
|
714
780
|
| `log: <regex>` | `from` (a service; default the app), `within` (ms, default 5000). Waits for a matching line printed during the scenario. |
|
|
715
781
|
| `changes: { <table>: { inserted, updated, deleted } }` | Each is a count or a list of subset rows (`updated` matches the row after the update). Tables that aren't listed must be unchanged. |
|
|
716
782
|
| `checkpoint: true` | Later `changes` steps only see what happens after this step. |
|
|
717
783
|
| `mail: { to, from, subject, text, html }` | `times` (exact; default at least one), `within` (ms, default 5000), `capture` from the last match (`subject`, `text`, `links.0`). Waits for mail the app sends. `{}` matches any message. |
|
|
784
|
+
| `use: <definition>` | `with: { param: value }`. Runs the steps of a `define:` entry. |
|
|
718
785
|
| `snapshot: true` | The scenario's [trace](#snapshot-the-whole-scenario-trace) so far must match its stored snapshot. `mask: [keys]` hides more values. |
|
|
719
786
|
|
|
720
787
|
`db`, `sql`, `received` and `changes` steps take `within: <ms>` to retry until they pass, for effects the app applies asynchronously.
|
|
@@ -723,8 +790,36 @@ scenarios:
|
|
|
723
790
|
- `{{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.
|
|
724
791
|
- Expected `json`, `rows` and `headers` are subsets: extra keys are fine. `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "..." }` and `{ $any: true }` match loosely.
|
|
725
792
|
- A file-level `setup:` list runs at the start of every scenario. `skip`, `only` and `timeout` work per scenario.
|
|
793
|
+
- A file-level `define:` names step lists that `use:` steps run, like functions: `params` are given with `with:` and are `{{variables}}` inside, and what the steps capture is visible after the `use` (see below).
|
|
726
794
|
- Mistakes are reported with the file and line before anything runs (`polls.scenario.yaml:12: unknown key "stauts" in expect`). A failing step reports its file, line and step number. The JSON Schema in `schema/` gives editors completion and inline errors.
|
|
727
795
|
|
|
796
|
+
### Reusing steps: `define` and `use`
|
|
797
|
+
|
|
798
|
+
```yaml
|
|
799
|
+
define:
|
|
800
|
+
signed up:
|
|
801
|
+
params: [email]
|
|
802
|
+
steps:
|
|
803
|
+
- request: POST /signup
|
|
804
|
+
json: { email: "{{email}}", password: hunter22 }
|
|
805
|
+
expect: { status: 201 }
|
|
806
|
+
capture: { userId: json.id }
|
|
807
|
+
- request: POST /login
|
|
808
|
+
json: { email: "{{email}}", password: hunter22 }
|
|
809
|
+
capture: { token: json.token }
|
|
810
|
+
|
|
811
|
+
scenarios:
|
|
812
|
+
- name: a new user has an empty cart
|
|
813
|
+
steps:
|
|
814
|
+
- use: signed up
|
|
815
|
+
with: { email: ada@example.com }
|
|
816
|
+
- request: GET /users/{{userId}}/cart
|
|
817
|
+
headers: { authorization: "Bearer {{token}}" }
|
|
818
|
+
expect: { json: { items: [] } }
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
A definition is a list of steps, or `{ params, steps }`, and may `use` other definitions. Unknown names, missing or extra `with` keys and definitions that use themselves are reported with their line before anything runs. A failing step inside one names the whole path: `cart.scenario.yaml:5 (a new user has an empty cart, step 1: use signed up → step 2: POST /login)`.
|
|
822
|
+
|
|
728
823
|
### Without any JavaScript: `npx slicetest`
|
|
729
824
|
|
|
730
825
|
Put the plugin options in `slicetest.config.yaml` and run the CLI. It needs Node, but no `package.json` scripts, TypeScript or Vitest config. To add TypeScript scenarios later, `slicetest()` without options in a `vitest.config.ts` reads the same file (`slicetest("path/to/config.yaml")` for another one):
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ const HELP = `Usage: slicetest [filters...] [options]
|
|
|
15
15
|
slicetest gen [--spec <file>] [--out <dir>] [--uncovered] [--force]
|
|
16
16
|
slicetest doctor [--config <file>]
|
|
17
17
|
slicetest record [--out <file>] [--port <n>]
|
|
18
|
+
slicetest import <file.har> [--stub <name>] [--upstream <url>]
|
|
18
19
|
|
|
19
20
|
Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
|
|
20
21
|
\`slicetest init\` looks at the project and writes a starting config and scenario.
|
|
@@ -24,6 +25,9 @@ app's OpenAPI spec; with --uncovered, only for those the last run didn't produce
|
|
|
24
25
|
migrations, commands and spec files, and says what to fix.
|
|
25
26
|
\`slicetest record\` starts everything and a proxy in front of the app: use the app
|
|
26
27
|
through it (a browser, curl), press Enter, and get the session as a YAML scenario.
|
|
28
|
+
\`slicetest import\` turns a HAR file (the browser's network panel: "Save all as
|
|
29
|
+
HAR"; Charles, mitmproxy, Proxyman) into recordings for the stubs whose
|
|
30
|
+
\`upstream\` it has requests for, so they replay the real service's answers.
|
|
27
31
|
|
|
28
32
|
Options:
|
|
29
33
|
-c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
|
|
@@ -33,8 +37,12 @@ Options:
|
|
|
33
37
|
--out <dir> gen: where to write scenarios (default: scenarios)
|
|
34
38
|
record: the scenario file (default: scenarios/recorded-<time>.scenario.yaml)
|
|
35
39
|
--port <n> record: the proxy's port (default: any free port)
|
|
40
|
+
--stub <name> import: only this stub (with --upstream, one not in the config)
|
|
41
|
+
--upstream <url> import: the real service's base URL for --stub
|
|
36
42
|
--uncovered gen: only responses the last run didn't cover
|
|
37
43
|
--force init, gen: overwrite existing files
|
|
44
|
+
--diagrams <dir> Write a Mermaid sequence diagram of every scenario to <dir>,
|
|
45
|
+
one Markdown page per scenario file
|
|
38
46
|
-h, --help Show this help
|
|
39
47
|
|
|
40
48
|
Config (paths are relative to the config file):
|
|
@@ -47,6 +55,8 @@ Config (paths are relative to the config file):
|
|
|
47
55
|
auth: true | { audience, claims } OpenID issuer at {{auth.issuer}} / {{auth.jwks}}
|
|
48
56
|
openapi: file | { spec, minCoverage }
|
|
49
57
|
http: { headers, query }
|
|
58
|
+
offline: true refuse calls to hosts no stub answers
|
|
59
|
+
strictStubs: true fail scenarios with stub routes the app never called
|
|
50
60
|
include: [globs] default ["**/*.scenario.{yaml,yml}"]
|
|
51
61
|
`;
|
|
52
62
|
export async function main(argv = process.argv.slice(2)) {
|
|
@@ -63,6 +73,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
63
73
|
out: { type: "string" },
|
|
64
74
|
uncovered: { type: "boolean" },
|
|
65
75
|
port: { type: "string" },
|
|
76
|
+
diagrams: { type: "string" },
|
|
77
|
+
stub: { type: "string" },
|
|
78
|
+
upstream: { type: "string" },
|
|
66
79
|
},
|
|
67
80
|
});
|
|
68
81
|
if (values.help) {
|
|
@@ -106,6 +119,36 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
106
119
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
107
120
|
return;
|
|
108
121
|
}
|
|
122
|
+
if (positionals[0] === "import") {
|
|
123
|
+
const har = positionals[1];
|
|
124
|
+
if (!har)
|
|
125
|
+
throw new Error("slicetest import: which HAR file? e.g. npx slicetest import session.har");
|
|
126
|
+
const config = configPath && existsSync(configPath) ? (parse(await readFile(configPath, "utf8")) ?? {}) : undefined;
|
|
127
|
+
const root = configPath && config ? path.dirname(configPath) : process.cwd();
|
|
128
|
+
const declared = (config?.stubs ?? []).flatMap((s) => (typeof s === "object" && s.upstream ? [s] : []));
|
|
129
|
+
let targets = declared.map((s) => ({ name: s.name, upstream: s.upstream, file: path.resolve(root, s.recordings ?? `recordings/${s.name}.yaml`) }));
|
|
130
|
+
if (values.stub) {
|
|
131
|
+
const known = targets.find((t) => t.name === values.stub);
|
|
132
|
+
const upstream = values.upstream ?? known?.upstream;
|
|
133
|
+
if (!upstream)
|
|
134
|
+
throw new Error(`slicetest import: stub "${values.stub}" has no upstream in the config; pass --upstream https://api.example.com`);
|
|
135
|
+
targets = [{ name: values.stub, upstream, file: known?.file ?? path.resolve(root, `recordings/${values.stub}.yaml`) }];
|
|
136
|
+
}
|
|
137
|
+
if (targets.length === 0)
|
|
138
|
+
throw new Error("slicetest import: no stub to import into. Give stubs an `upstream` in the config, or pass --stub <name> --upstream <url>.");
|
|
139
|
+
const { importHar } = await import("./har.js");
|
|
140
|
+
const { written, skipped, others } = await importHar(path.resolve(har), targets);
|
|
141
|
+
const lines = [
|
|
142
|
+
...written.map((w) => ` ${w.name}: ${w.count} recording(s) → ${path.relative(process.cwd(), w.file)}`),
|
|
143
|
+
...(written.length === 0 ? [`No requests in ${har} go to ${targets.map((t) => t.upstream).join(", ")}.`] : []),
|
|
144
|
+
...(skipped ? [` skipped ${skipped} preflight, aborted or binary request(s)`] : []),
|
|
145
|
+
...(others.length ? ["", "Requests to other hosts (not imported):", ...others.slice(0, 10).map(([h, n]) => ` ${h} (${n})`), "Import one with --stub <name> --upstream <url>."] : []),
|
|
146
|
+
];
|
|
147
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
148
|
+
if (written.length === 0)
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
109
152
|
if (!configPath || !existsSync(configPath)) {
|
|
110
153
|
process.stderr.write(`slicetest: no config found. Run "npx slicetest init" to create ${CONFIG_NAMES[0]} (see --help).\n`);
|
|
111
154
|
process.exitCode = 1;
|
|
@@ -117,6 +160,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
117
160
|
await record(configPath, options, { out: values.out, port: values.port ? Number(values.port) : 0 });
|
|
118
161
|
return;
|
|
119
162
|
}
|
|
163
|
+
// Workers inherit the environment; a relative directory is taken from where the command runs.
|
|
164
|
+
if (values.diagrams)
|
|
165
|
+
process.env.SLICETEST_DIAGRAMS = path.resolve(values.diagrams);
|
|
120
166
|
const { startVitest, version } = await import("vitest/node");
|
|
121
167
|
if (Number.parseInt(version, 10) < 4) {
|
|
122
168
|
process.stderr.write(`slicetest: needs Vitest 4 or later, and this project has Vitest ${version}. Upgrade it (npm i -D vitest@latest), or run slicetest from a folder with its own package.json.\n`);
|
package/dist/config.d.ts
CHANGED
|
@@ -52,6 +52,11 @@ export interface SlicetestOptions {
|
|
|
52
52
|
* scenario, naming the host, so a forgotten stub can't reach a real service.
|
|
53
53
|
*/
|
|
54
54
|
offline?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Fail a scenario that registered a stub route the app never called: the test may not be
|
|
57
|
+
* exercising what it set up. Routes marked `.optional()` (YAML `optional: true`) are exempt.
|
|
58
|
+
*/
|
|
59
|
+
strictStubs?: boolean;
|
|
55
60
|
/**
|
|
56
61
|
* Most Vitest workers to run test files in (Vitest's `maxWorkers`). Each worker has
|
|
57
62
|
* its own app and database; with `app.scope: "worker"`, fewer workers means fewer app
|
|
@@ -204,6 +209,12 @@ export type MigrateOptions = {
|
|
|
204
209
|
* without `inputs`, the command runs on every run.
|
|
205
210
|
*/
|
|
206
211
|
inputs?: string[];
|
|
212
|
+
/**
|
|
213
|
+
* Extra environment for the command, for tools that don't read `DATABASE_URL`
|
|
214
|
+
* (Laravel's `DB_HOST`, EF Core's connection string): `{ DB_HOST: "{{db.host}}" }`.
|
|
215
|
+
* The command itself may use the same `{{db.*}}` placeholders.
|
|
216
|
+
*/
|
|
217
|
+
env?: Record<string, string>;
|
|
207
218
|
};
|
|
208
219
|
/** Normalized shape passed from the plugin to globalSetup and workers. Must stay JSON-serializable. */
|
|
209
220
|
export interface ResolvedOptions {
|
|
@@ -216,6 +227,7 @@ export interface ResolvedOptions {
|
|
|
216
227
|
containers: Record<string, ContainerOptions>;
|
|
217
228
|
mail: boolean;
|
|
218
229
|
offline: boolean;
|
|
230
|
+
strictStubs: boolean;
|
|
219
231
|
workers?: number;
|
|
220
232
|
auth: AuthOptions | false;
|
|
221
233
|
db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse"> & {
|
package/dist/config.js
CHANGED
|
@@ -9,6 +9,7 @@ export function resolveOptions(opts, root) {
|
|
|
9
9
|
containers: opts.containers ?? {},
|
|
10
10
|
mail: opts.mail ?? false,
|
|
11
11
|
offline: opts.offline ?? false,
|
|
12
|
+
strictStubs: opts.strictStubs ?? false,
|
|
12
13
|
workers: opts.workers,
|
|
13
14
|
auth: opts.auth === true ? {} : (opts.auth ?? false),
|
|
14
15
|
db: opts.db === false ? { ...resolveDb({}), none: true } : resolveDb(opts.db ?? {}),
|
|
@@ -104,6 +105,8 @@ function validate(opts) {
|
|
|
104
105
|
fail(`workers must be a positive whole number, got ${JSON.stringify(opts.workers)}`);
|
|
105
106
|
if (opts.offline !== undefined && typeof opts.offline !== "boolean")
|
|
106
107
|
fail(`offline must be true or false, got ${JSON.stringify(opts.offline)}`);
|
|
108
|
+
if (opts.strictStubs !== undefined && typeof opts.strictStubs !== "boolean")
|
|
109
|
+
fail(`strictStubs must be true or false, got ${JSON.stringify(opts.strictStubs)}`);
|
|
107
110
|
if (opts.mail !== undefined && typeof opts.mail !== "boolean")
|
|
108
111
|
fail(`mail must be true or false, got ${JSON.stringify(opts.mail)}`);
|
|
109
112
|
if (opts.auth !== undefined && typeof opts.auth !== "boolean") {
|
|
@@ -136,6 +139,13 @@ function validate(opts) {
|
|
|
136
139
|
const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
|
|
137
140
|
if (keys.length !== 1)
|
|
138
141
|
fail(`db.migrate takes exactly one of atlas / sql / command, got ${keys.join(", ") || "none"}`);
|
|
142
|
+
const menv = migrate.env;
|
|
143
|
+
if (menv !== undefined) {
|
|
144
|
+
if (!("command" in migrate))
|
|
145
|
+
fail("db.migrate.env is for a migration `command`; atlas and sql get the database URL themselves");
|
|
146
|
+
if (!menv || typeof menv !== "object" || Array.isArray(menv) || Object.values(menv).some((v) => typeof v !== "string"))
|
|
147
|
+
fail('db.migrate.env maps variable names to strings, e.g. { DB_HOST: "{{db.host}}" }');
|
|
148
|
+
}
|
|
139
149
|
}
|
|
140
150
|
const oas = opts.openapi;
|
|
141
151
|
if (oas !== undefined) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
|
|
3
|
+
* `spring.datasource.url` plus username / password), or separate host / port / name settings.
|
|
4
|
+
*/
|
|
5
|
+
export declare function connectionVars(engine: string, url: string, sqlitePath?: string): Record<string, string>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
|
|
3
|
+
* `spring.datasource.url` plus username / password), or separate host / port / name settings.
|
|
4
|
+
*/
|
|
5
|
+
export function connectionVars(engine, url, sqlitePath) {
|
|
6
|
+
if (engine === "sqlite")
|
|
7
|
+
return { "db.jdbcUrl": `jdbc:sqlite:${sqlitePath}`, "db.adoNet": `Data Source=${sqlitePath}` };
|
|
8
|
+
const u = new URL(url);
|
|
9
|
+
const port = u.port || (engine === "mysql" ? "3306" : "5432");
|
|
10
|
+
const name = decodeURIComponent(u.pathname.replace(/^\//, ""));
|
|
11
|
+
return {
|
|
12
|
+
"db.host": u.hostname,
|
|
13
|
+
"db.port": port,
|
|
14
|
+
"db.name": name,
|
|
15
|
+
"db.user": decodeURIComponent(u.username),
|
|
16
|
+
"db.password": decodeURIComponent(u.password),
|
|
17
|
+
"db.jdbcUrl": `jdbc:${engine === "mysql" ? "mysql" : "postgresql"}://${u.hostname}:${port}/${encodeURIComponent(name)}`,
|
|
18
|
+
// ADO.NET (Npgsql, MySqlConnector), as .NET apps read `ConnectionStrings__Default`.
|
|
19
|
+
"db.adoNet": [
|
|
20
|
+
`${engine === "mysql" ? "Server" : "Host"}=${u.hostname}`,
|
|
21
|
+
`Port=${port}`,
|
|
22
|
+
`Database=${name}`,
|
|
23
|
+
`${engine === "mysql" ? "User ID" : "Username"}=${decodeURIComponent(u.username)}`,
|
|
24
|
+
`Password=${adoValue(decodeURIComponent(u.password))}`,
|
|
25
|
+
].join(";"),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** ADO.NET values with `;` or quotes are quoted. */
|
|
29
|
+
function adoValue(v) {
|
|
30
|
+
return /[;'"]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
31
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Changes } from "./db.js";
|
|
2
|
+
import type { HttpResponse } from "./http.js";
|
|
3
|
+
import type { Mailbox } from "./mail.js";
|
|
4
|
+
import type { Stub } from "./stub.js";
|
|
5
|
+
/**
|
|
6
|
+
* A Mermaid sequence diagram of what a scenario did: each request to the app,
|
|
7
|
+
* the stub calls the app made while answering it, the mail it sent and the
|
|
8
|
+
* database tables it changed. GitHub, GitLab and most Markdown viewers render it.
|
|
9
|
+
*/
|
|
10
|
+
export declare function sequenceDiagram(history: readonly HttpResponse[], stubs: Iterable<Stub>, changes: Changes | undefined, mailbox?: Mailbox): string;
|
|
11
|
+
export declare function diagramPage(file: string, title: string, scenario: string, diagram: string, failed: boolean): string;
|
|
12
|
+
/** A collapsed section for the GitHub Actions job summary. */
|
|
13
|
+
export declare function failureDiagram(where: string, scenario: string, diagram: string): string;
|
package/dist/diagram.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describeGraphQL } from "./graphql.js";
|
|
2
|
+
import { timeline } from "./timeline.js";
|
|
3
|
+
/**
|
|
4
|
+
* A Mermaid sequence diagram of what a scenario did: each request to the app,
|
|
5
|
+
* the stub calls the app made while answering it, the mail it sent and the
|
|
6
|
+
* database tables it changed. GitHub, GitLab and most Markdown viewers render it.
|
|
7
|
+
*/
|
|
8
|
+
export function sequenceDiagram(history, stubs, changes, mailbox) {
|
|
9
|
+
const events = [];
|
|
10
|
+
const participants = new Map();
|
|
11
|
+
const id = (name, label) => {
|
|
12
|
+
const key = `s_${name.replace(/[^A-Za-z0-9_]/g, "_")}`;
|
|
13
|
+
if (!participants.has(key))
|
|
14
|
+
participants.set(key, label);
|
|
15
|
+
return key;
|
|
16
|
+
};
|
|
17
|
+
let order = 0;
|
|
18
|
+
const add = (at, line) => events.push({ at, order: order++, line });
|
|
19
|
+
for (const res of history) {
|
|
20
|
+
const t = timeline.get(res) ?? { start: Infinity, end: Infinity };
|
|
21
|
+
const external = /^https?:/.test(res.url);
|
|
22
|
+
const target = external ? id(`ext_${new URL(res.url).host}`, new URL(res.url).host) : "app";
|
|
23
|
+
const path = external ? new URL(res.url).pathname : res.url;
|
|
24
|
+
add(t.start, `test->>+${target}: ${text(`${res.method} ${path}`)}`);
|
|
25
|
+
add(t.end ?? t.start, `${target}-->>-test: ${res.status || "failed"}${summary(res)}`);
|
|
26
|
+
}
|
|
27
|
+
for (const stub of stubs) {
|
|
28
|
+
for (const call of stub.calls()) {
|
|
29
|
+
const t = timeline.get(call) ?? { start: Infinity };
|
|
30
|
+
const s = id(stub.name, `${stub.name} (stub)`);
|
|
31
|
+
add(t.start, `app->>+${s}: ${text(describeCall(call))}`);
|
|
32
|
+
const reply = call.fault === "reset" ? "connection dropped" : call.response ? `${call.response.status}${call.fault ? " (chaos)" : ""}` : call.matched ? "no answer" : "501 no stub";
|
|
33
|
+
add(t.end ?? t.start, `${s}-->>-app: ${reply}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
events.sort((a, b) => a.at - b.at || a.order - b.order);
|
|
37
|
+
const lines = ["sequenceDiagram", " participant test as scenario", " participant app"];
|
|
38
|
+
for (const [key, label] of participants)
|
|
39
|
+
lines.push(` participant ${key} as ${text(label)}`);
|
|
40
|
+
if (mailbox && mailbox.messages().length)
|
|
41
|
+
lines.push(" participant mail");
|
|
42
|
+
const tables = Object.entries(changes ?? {}).filter(([, c]) => c.inserted.length || c.updated.length || c.deleted.length);
|
|
43
|
+
if (tables.length)
|
|
44
|
+
lines.push(" participant db as database");
|
|
45
|
+
for (const e of events)
|
|
46
|
+
lines.push(` ${e.line}`);
|
|
47
|
+
for (const m of mailbox?.messages() ?? [])
|
|
48
|
+
lines.push(` app->>mail: ${text(`${m.subject} to ${m.to.join(", ")}`)}`);
|
|
49
|
+
if (tables.length) {
|
|
50
|
+
const counts = tables.map(([table, c]) => `${table} ${[c.inserted.length && `+${c.inserted.length}`, c.updated.length && `~${c.updated.length}`, c.deleted.length && `-${c.deleted.length}`].filter(Boolean).join(" ")}`);
|
|
51
|
+
lines.push(` Note over app,db: ${text(counts.join(", "))}`);
|
|
52
|
+
}
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
function describeCall(call) {
|
|
56
|
+
if (call.graphql)
|
|
57
|
+
return describeGraphQL(call.graphql);
|
|
58
|
+
return `${call.method} ${call.path}`;
|
|
59
|
+
}
|
|
60
|
+
/** A short hint of the response: a JSON error message or the id it created. */
|
|
61
|
+
function summary(res) {
|
|
62
|
+
const j = res.json;
|
|
63
|
+
if (j && typeof j === "object" && !Array.isArray(j)) {
|
|
64
|
+
for (const key of ["error", "message", "id"]) {
|
|
65
|
+
const v = j[key];
|
|
66
|
+
if (typeof v === "string" || typeof v === "number")
|
|
67
|
+
return ` ${text(`${key}: ${v}`)}`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
/** Mermaid ends a message at `;` and reads `#…;` as an entity, so both are escaped; long text is cut. */
|
|
73
|
+
function text(s, max = 70) {
|
|
74
|
+
const cut = s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
75
|
+
return cut.replace(/[\r\n]+/g, " ").replace(/[#;]/g, (c) => (c === "#" ? "#35;" : "#59;"));
|
|
76
|
+
}
|
|
77
|
+
/** Scenario diagrams per test file, for `SLICETEST_DIAGRAMS` / `--diagrams`: one Markdown page per file, in run order. */
|
|
78
|
+
const pages = new Map();
|
|
79
|
+
export function diagramPage(file, title, scenario, diagram, failed) {
|
|
80
|
+
const page = pages.get(file) ?? { title, sections: new Map() };
|
|
81
|
+
pages.set(file, page);
|
|
82
|
+
page.sections.set(scenario, [`## ${scenario}${failed ? " (failed)" : ""}`, "", "```mermaid", diagram, "```", ""].join("\n"));
|
|
83
|
+
return [`# ${page.title}`, "", `Sequence diagrams of the scenarios in \`${page.title}\`, written by slicetest. Regenerated on every run.`, "", ...page.sections.values()].join("\n");
|
|
84
|
+
}
|
|
85
|
+
/** A collapsed section for the GitHub Actions job summary. */
|
|
86
|
+
export function failureDiagram(where, scenario, diagram) {
|
|
87
|
+
const name = scenario.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c]);
|
|
88
|
+
return [`<details><summary>✗ ${name} <code>${where}</code>: what happened</summary>`, "", "```mermaid", diagram, "```", "", "</details>", ""].join("\n");
|
|
89
|
+
}
|