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 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`), 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.
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
- registered on mail: POST /other
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? }` (gets `DATABASE_URL`). |
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), `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}}`. |
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`, `when`, `times` (exact; default at least once) |
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;
@@ -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) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c]);
88
+ return [`<details><summary>✗ ${name} <code>${where}</code>: what happened</summary>`, "", "```mermaid", diagram, "```", "", "</details>", ""].join("\n");
89
+ }