slicetest 0.5.0 → 0.6.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
@@ -55,6 +55,8 @@ npx slicetest # starts Postgres, migrates, starts your app, runs scenario
55
55
  - **Real token verification, any user.** `auth: true` gives the app an OpenID issuer with a JWKS, so JWT checks stay on in tests, and scenarios mint tokens with any claims, including expired or foreign-signed ones.
56
56
  - **Webhooks signed like the real sender.** Stripe, GitHub, Slack, Shopify and Standard Webhooks signatures, plus forged and replayed deliveries, so signature checks are tested instead of bypassed.
57
57
  - **Reproducible chaos.** Stubs can fail the first calls, drop connections or add latency, from a seed the failure output prints, so a resilience test that fails once fails again on demand.
58
+ - **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
+ - **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.
58
60
  - **N+1 detection for any stack.** A wire-protocol proxy records the SQL the app runs, so query counts are asserted at the HTTP boundary, whatever the ORM or language.
59
61
  - **Postgres, MySQL or SQLite**, with the same scenarios and the same helpers on all three, plus Redis, MinIO or any other `containers` reset between scenarios.
60
62
  - **Fast resets.** `TRUNCATE` between scenarios (about 1.5 ms) with the app still running, and a cached migrated template, so the second run skips container start-up and migrations.
@@ -171,12 +173,30 @@ res.status; res.headers; res.text; res.json; res.durationMs;
171
173
  await http.get("/polls", { query: { page: 2 }, headers: { accept: "text/html" } });
172
174
  await http.post("/login", http.form({ user: "a", pass: "b" })); // urlencoded; FormData, Blob and bytes also work
173
175
  await http.get("/old-path", { follow: true }); // redirects are NOT followed by default
176
+ await http.submit(await http.get("/signup"), { button: "Sign up", fields: { email: "a@b.test" } }); // a form, as a browser sends it
174
177
 
175
178
  const admin = http.with({ headers: { authorization: `Bearer ${token}` } }); // shares cookies with http
176
179
  http.cookies.get("session"); // cookies persist within a scenario
177
180
  ```
178
181
 
179
- Requests may only go to the app under test; absolute URLs to other hosts are rejected. Defaults for every request can be set with `http: { headers }` in the plugin config.
182
+ Requests may only go to the app under test; absolute URLs to other hosts are rejected. Defaults for every request can be set with `http: { headers }` in the plugin config. With `follow`, cookies set by each redirect are kept (a login answering `302` with `Set-Cookie`), `303` and `301`/`302` turn into a `GET` as in browsers, and a redirect to another host is returned instead of followed.
183
+
184
+ #### Forms: `http.submit()`
185
+
186
+ `http.submit(page, opts)` sends a form of a page the app returned, the way a browser with JavaScript off does: every field with its value, the hidden ones included, plus the button that was pressed. So the test doesn't need to know about CSRF tokens (Django, Rails, Laravel) or the action ids and bound arguments of **Next.js server actions** — they are hidden inputs, sent along like a browser sends them.
187
+
188
+ ```ts
189
+ const page = await http.get(`/q/${q.id}`); // a Next.js page with <button formAction={vote.bind(null, id, "a")}>
190
+ const res = await http.submit(page, { button: "Dogs", follow: true });
191
+ expect(res.text).toContain("Your choice");
192
+
193
+ await http.submit(await http.get("/settings"), {
194
+ form: "profile", // by id, name or position, when the page has several
195
+ fields: { name: "Ada", newsletter: true, tags: ["a", "b"] }, // checkboxes and radios by true / false / value
196
+ });
197
+ ```
198
+
199
+ `button` matches a submit button's text, `value`, `name` or `id`, and picks its form; its `formaction` / `formmethod` / `formenctype` apply. `fields` replace what the page had and must name existing fields, so a typo fails. When nothing matches, the error lists the page's forms and their buttons. In YAML, `submit:` uses the page the previous step requested.
180
200
 
181
201
  ### `db` — arrange and inspect the real database
182
202
 
@@ -312,6 +332,25 @@ To fail the run below a threshold, use `openapi: { spec: "openapi.yaml", minCove
312
332
 
313
333
  The example apps in `examples/` run every scenario against `examples/openapi.yaml` with `minCoverage: 100`, and their Slack calls against `examples/slack.openapi.yaml`.
314
334
 
335
+ #### Streaming replies: `sse()`
336
+
337
+ LLM APIs stream their answers as Server-Sent Events. `sse(events)` makes such a reply, each event as `[event, data]` or `{ event, data, id }`, data that isn't a string as JSON:
338
+
339
+ ```ts
340
+ import { sse } from "slicetest";
341
+
342
+ stub("anthropic").on("POST", "/v1/messages").reply(sse([
343
+ ["message_start", { type: "message_start", message: { id: "msg_1", type: "message", role: "assistant", content: [], model: "claude-sonnet-4-6", usage: { input_tokens: 5, output_tokens: 1 } } }],
344
+ ["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }],
345
+ ["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } }],
346
+ ["content_block_stop", { type: "content_block_stop", index: 0 }],
347
+ ["message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }],
348
+ ["message_stop", { type: "message_stop" }],
349
+ ]));
350
+ ```
351
+
352
+ In YAML, `reply: { sse: [{ event: message_start, data: { ... } }, ...] }` instead of `body`.
353
+
315
354
  #### Chaos: faults the app must survive
316
355
 
317
356
  `chaos()` makes a stub misbehave for the rest of the scenario, to test retries, timeouts and fallbacks against the app's real HTTP client:
@@ -327,6 +366,54 @@ stub("search").chaos({ errorRate: 0.3, networkErrorRate: 0.1, latency: [50, 300]
327
366
 
328
367
  Faulted calls don't use up `once()` / `times()` routes, so a retry gets the answer you registered. 429 and 503 come with `Retry-After: 1`. Random faults are drawn from a seeded generator: a failing scenario prints `chaos on search: …; 4 of 12 calls faulted. Replay with SLICETEST_CHAOS_SEED=1840211`, and running with that variable gives the same faults. `stub.faults()` lists the calls that faulted. YAML: `- chaos: payments` with `failFirst`, `errorRate`, `statuses`, `networkErrorRate`, `latency` and `seed`.
329
368
 
369
+ ### Hard-coded hosts: `hosts`
370
+
371
+ Stubs normally take over by giving the app their URL (`{{stub.github}}`) instead of the real one. When the URL is written in the code (`https://api.github.com`), or built into a framework (Spring Security's GitHub login), give the stub the hosts instead:
372
+
373
+ ```yaml
374
+ stubs:
375
+ - name: github
376
+ hosts: [github.com] # OAuth authorize and token endpoints
377
+ - name: github-api
378
+ hosts: [api.github.com]
379
+ ```
380
+
381
+ The app is then started with `HTTPS_PROXY` / `HTTP_PROXY` pointing at slicetest and a certificate authority made for the run in the trust settings each runtime reads: `NODE_USE_ENV_PROXY` and `NODE_EXTRA_CA_CERTS` for Node (22.21+ / 24.5+), `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` for Python, Ruby, Go and curl, and proxy and trust-store system properties in `JAVA_TOOL_OPTIONS` for the JVM (`HttpURLConnection`, `java.net.http.HttpClient`, Spring's `RestClient` and `RestTemplate`). Calls to those hosts reach the stub with their path and `Host` header, over HTTPS or HTTP; calls to other hosts go to the real ones and are listed in the failure output. Nothing changes in the app. Variables you set in `app.env` win over these, and `{{proxy.url}}`, `{{proxy.ca}}`, `{{proxy.bundle}}` and `{{proxy.truststore}}` are there for clients configured some other way.
382
+
383
+ `*.connpass.com` covers every subdomain (one stub for `findy.connpass.com`, `mercari.connpass.com`, …); the stub sees the `Host` header, so a reply can depend on it (`{{call.headers.host}}`, or `call.headers.host` in a reply function).
384
+
385
+ With `offline: true`, the app can only reach localhost and the stubs' hosts. A call anywhere else is refused and fails the scenario with the host's name, so a forgotten stub can't quietly reach a real service, and the first run tells you which hosts to add:
386
+
387
+ ```
388
+ slicetest: offline: the app tried to reach api.lu.ma, which no stub answers. Add it to a stub's `hosts` (or remove `offline`).
389
+ ```
390
+
391
+ Node apps also get a small preload (`NODE_OPTIONS=--require …`) that gives the proxy settings to `http.Agent`s that libraries make themselves (the Stripe SDK, many API clients), which `NODE_USE_ENV_PROXY` alone doesn't reach. A client in any language that ignores proxy settings altogether goes straight to the real host; `offline` can't stop what doesn't pass through it.
392
+
393
+ The proxy settings reach every process the app command starts. When that command is a build tool (`gradle bootRun`, `mvn spring-boot:run`, `go run`), its own downloads go through the proxy too, and `offline` refuses them; the failure says so when the host is a package registry. Download dependencies in `app.build` (`./gradlew bootJar`, `mvn package`, which `slicetest init` sets up for Spring Boot), or start a built artifact.
394
+
395
+ A redirect to an intercepted host is followed to its stub, as a browser would follow it to the real site. So a whole OAuth login runs in a scenario: the stub plays the provider's consent page and sends the browser back.
396
+
397
+ ```yaml
398
+ setup:
399
+ - stub: github
400
+ on: GET /login/oauth/authorize
401
+ reply: { status: 302, headers: { location: "{{call.query.redirect_uri}}?code=c1&state={{call.query.state}}" } }
402
+ - stub: github
403
+ on: POST /login/oauth/access_token
404
+ reply: { body: { access_token: gho_test, token_type: bearer } }
405
+ - stub: github-api
406
+ on: GET /user
407
+ reply: { body: { id: 1, login: octocat } }
408
+ scenarios:
409
+ - name: log in with GitHub
410
+ steps:
411
+ - request: GET /oauth2/authorization/github
412
+ follow: true # → github.com (stub) → back to the app's callback
413
+ - request: GET /api/me
414
+ expect: { json: { login: octocat } }
415
+ ```
416
+
330
417
  ### Recording a real service
331
418
 
332
419
  A stub can also answer from recordings of the real service, the way VCR or Polly do, except that it works for an app in any language because the stub is a server. Give it the real base URL:
@@ -464,6 +551,8 @@ scenario("the app rejects tokens it must not trust", async ({ http, auth }) => {
464
551
 
465
552
  `auth.token(claims, opts)` returns the JWT itself. Tokens get `iss`, `aud`, `sub: "user-1"`, `iat`, `nbf`, `exp` (1 hour, or `expiresIn`) and the configured `claims`, all overridable. `auth.rotate()` switches to a new signing key, to check that the app refetches the JWKS. Apps that fetch tokens themselves can use `POST {{auth.issuer}}/token` with the client-credentials grant: `sub` is the client id, and `scope` and `audience` are carried over. No dependencies: keys and signatures come from `node:crypto`.
466
553
 
554
+ For libraries that don't take an issuer URL: `{{auth.publicKey}}` is the signing key as a PEM public key, and `auth.jwks` the JWKS document, for a stub to serve at the provider's own URL (see [Clerk](#clerk)).
555
+
467
556
  In YAML, `auth` on a `request` step sends `Authorization: Bearer` with those claims (`auth: true` for the defaults):
468
557
 
469
558
  ```yaml
@@ -536,15 +625,19 @@ Scenarios in one file share an app and a database, so they always run one at a t
536
625
  | Option | Default | |
537
626
  |---|---|---|
538
627
  | `app.command` | (required) | Shell command. May use `{{app.port}}` and the other placeholders. |
539
- | `app.env` | `{ PORT, DATABASE_URL }` | Values may use `{{app.port}}`, `{{db.url}}`, `{{stub.<name>}}`. The rest of `process.env` is inherited. |
628
+ | `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
+ | `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. |
540
631
  | `app.cwd` | vitest root | |
541
632
  | `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
542
633
  | `app.readyTimeout` | `30000` | |
634
+ | `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`. |
543
635
  | `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
544
636
  | `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs? }` (gets `DATABASE_URL`). |
545
637
  | `db.seed` | none | SQL file re-run after every reset. |
546
638
  | `db.schemas` | `["public"]` | Schemas whose tables are reset. |
547
639
  | `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
640
+ | `db.neon` | `false` | For Neon's serverless driver over HTTP: `{{db.url}}` is a Neon-style URL and slicetest answers the driver's queries from the test database ([Neon](#neon-and-vercel-postgres)). |
548
641
  | `db.queries` | `false` | Point the app at a proxy that records its SQL (Postgres, MySQL), for [`db.queries()`](#dbqueries--the-sql-the-app-ran-from-any-language). |
549
642
  | `db.url` | `$SLICETEST_DATABASE_URL`, else a container | Use an existing Postgres server (e.g. a CI service container) instead of Testcontainers. |
550
643
  | `db.image` | `postgres:17-alpine` / `mysql:8.4` | |
@@ -553,8 +646,10 @@ Scenarios in one file share an app and a database, so they always run one at a t
553
646
  | `mail` | `false` | Start an SMTP server at `{{mail.host}}` / `{{mail.port}}` and collect the app's mail. See [Mail](#mail-catch-what-the-app-sends). |
554
647
  | `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). |
555
648
  | `services` | `{}` | Other processes: `{ name: { command, env?, cwd?, ready?, readyTimeout? } }`. Without `ready` a service is not waited for. |
556
- | `stubs` | `[]` | Names of stubbed services, or `{ name, openapi?, autoReply?, upstream?, recordings? }`: check calls against the provider's spec, answer from it, or [replay recordings](#recording-a-real-service) of the real service. |
557
- | `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`. Every response must match it; the run ends with a coverage report. |
649
+ | `offline` | `false` | Refuse the app's HTTP(S) calls to hosts no stub intercepts, and fail the scenario naming them. |
650
+ | `workers` | Vitest's default | Most Vitest workers (`maxWorkers`). Each has its own app and database. |
651
+ | `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
+ | `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. |
558
653
  | `http` | `{}` | Default `headers` / `query` for every request. |
559
654
 
560
655
  The config is validated up front: a missing `app.command`, an ambiguous `db.migrate` or a duplicate stub name fails with a clear message instead of a timeout.
@@ -606,6 +701,7 @@ scenarios:
606
701
  | Step | Keys |
607
702
  |---|---|
608
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}}`. |
704
+ | `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. |
609
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. |
610
706
  | `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
611
707
  | `request` with `auth` | `auth: true` or the claims: sends a bearer token from the `auth` issuer |
@@ -631,7 +727,7 @@ scenarios:
631
727
 
632
728
  ### Without any JavaScript: `npx slicetest`
633
729
 
634
- Put the plugin options in `slicetest.config.yaml` and run the CLI. It needs Node, but no `package.json` scripts, TypeScript or Vitest config:
730
+ 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):
635
731
 
636
732
  ```yaml
637
733
  # slicetest.config.yaml
@@ -705,6 +801,88 @@ Checks what a run needs before it starts, instead of failing with a timeout half
705
801
  1 problem(s) to fix before running.
706
802
  ```
707
803
 
804
+ ## Neon and Vercel Postgres
805
+
806
+ Apps on Neon's serverless driver (`neon()` from `@neondatabase/serverless`, `drizzle-orm/neon-http`, `@vercel/postgres`'s `sql`) send each query over HTTPS to Neon, not to a Postgres port. With `db: { neon: true }`, `{{db.url}}` is a Neon-style connection string, and slicetest answers the driver's HTTP queries (single statements and `transaction()` batches) from the test database. The driver, its type parsing and its errors (with Postgres's `code`) are the real ones; `db.queries` sees the SQL. `slicetest init` sets it when the driver is a dependency. The driver's WebSocket mode (`Pool`, `Client`) isn't covered.
807
+
808
+ With Drizzle, `drizzle-kit push` and `migrate` connect through the project's driver; with only Neon's installed, they try a WebSocket a local server doesn't answer, and exit 0 anyway (slicetest stops when a migration leaves the database without tables). Generate the SQL instead (`npx drizzle-kit generate`) and let slicetest apply it: `migrate: { sql: db/migrations }`, which `slicetest init` reads from `drizzle.config`'s `out`.
809
+
810
+ ## Clerk
811
+
812
+ Clerk verifies session tokens with keys from its Backend API, and reads users from it. Both are stubbed, and the tokens come from slicetest's issuer, so requests run as any user, with no Clerk account:
813
+
814
+ ```yaml
815
+ # slicetest.config.yaml (what `slicetest init` writes when @clerk/nextjs is a dependency)
816
+ app:
817
+ env:
818
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_Y2xlcmsuc2xpY2V0ZXN0LnRlc3Qk # base64 of "clerk.slicetest.test$"
819
+ CLERK_SECRET_KEY: sk_test_slicetest
820
+ auth: true
821
+ stubs:
822
+ - name: clerk
823
+ hosts: [api.clerk.com, clerk.slicetest.test]
824
+ ```
825
+
826
+ ```ts
827
+ function signIn({ auth, stub }: ScenarioContext, id = "user_1", email = "ada@example.com") {
828
+ stub("clerk").on("GET", "/v1/jwks").reply(200, auth.jwks); // the keys Clerk verifies tokens with
829
+ stub("clerk").on("GET", `/v1/users/${id}`).reply(200, {
830
+ object: "user", id, first_name: "Ada", last_name: null, primary_email_address_id: "e1",
831
+ email_addresses: [{ object: "email_address", id: "e1", email_address: email, verification: { status: "verified" }, linked_to: [] }],
832
+ phone_numbers: [], web3_wallets: [], external_accounts: [], created_at: 0, updated_at: 0,
833
+ });
834
+ return auth.header({ sub: id, sid: "sess_1" });
835
+ }
836
+
837
+ scenario("checkout as a signed-in user", async (ctx) => {
838
+ const res = await ctx.http.post("/api/checkout", undefined, { headers: signIn(ctx) });
839
+ expect(res).toHaveStatus(200);
840
+ });
841
+ ```
842
+
843
+ `CLERK_JWT_KEY` with `{{auth.publicKey}}` would avoid the JWKS stub, but Next.js inlines environment variables into middleware at build time, before the key exists. Without a token, `auth.protect()` in middleware answers API routes with 404.
844
+
845
+ ## Spring Boot and other JVM apps
846
+
847
+ `slicetest init` recognizes Spring Boot (Gradle or Maven, also in a `backend/` folder) and writes:
848
+
849
+ ```yaml
850
+ app:
851
+ cwd: backend
852
+ build: ./gradlew bootJar -q # built once: workers don't compile at once, bootRun downloads nothing
853
+ command: ./gradlew bootRun -q
854
+ env:
855
+ SERVER_PORT: "{{app.port}}"
856
+ SPRING_DATASOURCE_URL: "{{db.jdbcUrl}}" # overrides application.yml
857
+ SPRING_DATASOURCE_USERNAME: "{{db.user}}"
858
+ SPRING_DATASOURCE_PASSWORD: "{{db.password}}"
859
+ ready: { path: /actuator/health }
860
+ readyTimeout: 120000
861
+ scope: worker # one JVM per worker, kept for all of its test files
862
+ workers: 2
863
+ ```
864
+
865
+ Starting a JVM takes seconds, so by default (`scope: file`, one app per test file) the start-up dominates: 8 test files took 28 s on a Spring Boot 4 app. With `scope: worker` the app is started once per Vitest worker and kept for every file that worker runs, and `workers` caps how many there are: 18 s with the default worker count, 10 s with 2, 8 s with 1. Scenarios stay independent, since the database, stubs and cookies are reset between them either way; what's shared is the app process (and its in-memory state, such as caches).
866
+
867
+ Springdoc serves the spec at `/v3/api-docs`: `openapi: { fromApp: /v3/api-docs }` checks every response against it and reports its coverage, without a spec file in the repository. Generated specs often miss error responses and nullable fields, which this makes visible.
868
+
869
+ Apps that run Hibernate with `ddl-auto: validate` against schema-owning migrations (Atlas, or a migration command) work as they are: slicetest migrates, Hibernate validates the result at start-up. For a faster start, build a jar once with `build: ./gradlew bootJar -q` and run `command: java -jar build/libs/app.jar`. Calls to hard-coded hosts (GitHub, Google, …) are caught with [`hosts`](#hard-coded-hosts-hosts).
870
+
871
+ ## Next.js
872
+
873
+ Nothing Next-specific is needed beyond two settings. `slicetest init` writes both when `package.json` has `build` and `start` scripts:
874
+
875
+ ```yaml
876
+ app:
877
+ build: npm run build # `next start` serves the last build; without this, scenarios can pass against old code
878
+ command: npm start # next start reads $PORT
879
+ env: { PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}" }
880
+ ```
881
+
882
+ - **Server actions** are ordinary form posts when JavaScript is off. Request the page, then `submit:` the button; the action id and bound arguments travel in hidden inputs, so scenarios keep working when a build changes the ids.
883
+ - **Route handlers and pages** are plain HTTP: `request: GET /api/...` and `text: { $contains: ... }`. React may put `<!-- -->` between adjacent text nodes, so match on a word rather than on `75%` built from two values.
884
+ - **Rows inserted by migrations** (seed data in an Atlas or Prisma migration) are truncated before every scenario, like everything else. Create what a scenario needs with `make`, or move shared rows to `db.seed`.
885
+
708
886
  ## Examples
709
887
 
710
888
  `examples/` has a Node app (`node:http` + `pg`) and a Python app (`http.server` + `psycopg`) with the same API. **The same scenario files (`polls.test.ts` and `polls.scenario.yaml`) run against both**:
package/dist/app.js CHANGED
@@ -69,11 +69,11 @@ export class App {
69
69
  // A restarted service keeps its port, so URLs already handed to other processes stay valid.
70
70
  const port = fixedPort ?? (await freePort());
71
71
  vars = { ...vars, [`${key}.port`]: String(port) };
72
- const env = opts.env ?? { PORT: `{{${key}.port}}`, DATABASE_URL: "{{db.url}}" };
72
+ const env = opts.env ?? { PORT: `{{${key}.port}}`, ...("db.url" in vars ? { DATABASE_URL: "{{db.url}}" } : {}) };
73
73
  const child = spawn(interpolate(opts.command, vars, `${key}.command`), {
74
74
  shell: true,
75
75
  cwd: path.resolve(root, opts.cwd ?? "."),
76
- env: { ...process.env, ...mapValues(env, (v) => interpolate(v, vars, `${key}.env`)) },
76
+ env: { ...process.env, ...opts.baseEnv, ...mapValues(env, (v) => interpolate(v, vars, `${key}.env`)) },
77
77
  stdio: ["ignore", "pipe", "pipe"],
78
78
  // POSIX: own process group, so stop() also kills whatever the shell spawned.
79
79
  // Windows: detaching would open a console window; taskkill /T walks the tree instead.
package/dist/auth.d.ts CHANGED
@@ -31,6 +31,16 @@ export declare class Issuer {
31
31
  private constructor();
32
32
  static start(opts?: AuthOptions): Promise<Issuer>;
33
33
  get audience(): string;
34
+ /**
35
+ * The signing key as a PEM public key, for libraries that verify tokens with a key
36
+ * given in the environment instead of fetching a JWKS (Clerk's CLERK_JWT_KEY, …).
37
+ * The app gets it at start as {{auth.publicKey}}; tokens minted after `rotate()` use a key it doesn't know.
38
+ */
39
+ get publicKeyPem(): string;
40
+ /** The JWKS document itself (`{ keys: [...] }`), for a stub that serves keys at another provider's URL (Clerk's /v1/jwks, …). */
41
+ get jwks(): {
42
+ keys: Record<string, unknown>[];
43
+ };
34
44
  get jwksUrl(): string;
35
45
  /** A signed RS256 JWT. `claims` override the defaults (`sub: "user-1"`, `iss`, `aud`, `iat`, `exp`). */
36
46
  token(claims?: Record<string, unknown>, opts?: TokenOptions): string;
package/dist/auth.js CHANGED
@@ -4,7 +4,7 @@ const b64url = (data) => Buffer.from(data).toString("base64url");
4
4
  function newKey() {
5
5
  const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
6
6
  const kid = randomUUID();
7
- return { kid, privateKey, jwk: { ...publicKey.export({ format: "jwk" }), kid, alg: "RS256", use: "sig" } };
7
+ return { kid, privateKey, jwk: { ...publicKey.export({ format: "jwk" }), kid, alg: "RS256", use: "sig" }, pem: publicKey.export({ type: "spki", format: "pem" }) };
8
8
  }
9
9
  /**
10
10
  * An OpenID Connect issuer for the app under test: it publishes a discovery
@@ -45,6 +45,18 @@ export class Issuer {
45
45
  get audience() {
46
46
  return this.opts.audience;
47
47
  }
48
+ /**
49
+ * The signing key as a PEM public key, for libraries that verify tokens with a key
50
+ * given in the environment instead of fetching a JWKS (Clerk's CLERK_JWT_KEY, …).
51
+ * The app gets it at start as {{auth.publicKey}}; tokens minted after `rotate()` use a key it doesn't know.
52
+ */
53
+ get publicKeyPem() {
54
+ return this.#key.pem;
55
+ }
56
+ /** The JWKS document itself (`{ keys: [...] }`), for a stub that serves keys at another provider's URL (Clerk's /v1/jwks, …). */
57
+ get jwks() {
58
+ return { keys: [this.#key.jwk] };
59
+ }
48
60
  get jwksUrl() {
49
61
  return `${this.url}/.well-known/jwks.json`;
50
62
  }
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import type { SlicetestOptions } from "./config.js";
2
+ import { type SlicetestOptions } from "./config.js";
3
3
  export interface CliConfig extends SlicetestOptions {
4
4
  include?: string[];
5
5
  }
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { readFile } from "node:fs/promises";
9
9
  import path from "node:path";
10
10
  import { parseArgs } from "node:util";
11
11
  import { parse } from "yaml";
12
- const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetest.config.json"];
12
+ import { CONFIG_NAMES } from "./config.js";
13
13
  const HELP = `Usage: slicetest [filters...] [options]
14
14
  slicetest init [--force]
15
15
  slicetest gen [--spec <file>] [--out <dir>] [--uncovered] [--force]
@@ -117,9 +117,16 @@ export async function main(argv = process.argv.slice(2)) {
117
117
  await record(configPath, options, { out: values.out, port: values.port ? Number(values.port) : 0 });
118
118
  return;
119
119
  }
120
- const { startVitest } = await import("vitest/node");
120
+ const { startVitest, version } = await import("vitest/node");
121
+ if (Number.parseInt(version, 10) < 4) {
122
+ 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`);
123
+ process.exitCode = 1;
124
+ return;
125
+ }
121
126
  const { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
122
- const vitest = await startVitest(positionals, {
127
+ // Vitest 4 takes the mode ("test") first; 5 dropped it.
128
+ const start = (Number.parseInt(version, 10) === 4 ? startVitest.bind(null, "test") : startVitest);
129
+ const vitest = await start(positionals, {
123
130
  config: false,
124
131
  root: path.dirname(configPath),
125
132
  include: include ?? [YAML_SCENARIOS],
package/dist/config.d.ts CHANGED
@@ -2,7 +2,8 @@ import type { AuthOptions } from "./auth.js";
2
2
  import type { RequestOptions } from "./http.js";
3
3
  export interface SlicetestOptions {
4
4
  app: AppOptions;
5
- db?: DbOptions;
5
+ /** The database, or `false` for an app without one: no container, no resets, no `{{db.*}}`. */
6
+ db?: DbOptions | false;
6
7
  /**
7
8
  * Outbound HTTP services to stub. Each gets its own server, referenced as `{{stub.<name>}}` in `app.env`.
8
9
  * With `{ name, openapi }`, the app's calls to it and the stub's replies are checked against that service's spec.
@@ -18,7 +19,8 @@ export interface SlicetestOptions {
18
19
  * a lower coverage fails the run.
19
20
  */
20
21
  openapi?: string | {
21
- spec: string;
22
+ spec?: string;
23
+ fromApp?: string;
22
24
  minCoverage?: number;
23
25
  };
24
26
  /** Defaults for every request made with `http`, e.g. `{ headers: { accept: "application/json" } }`. */
@@ -44,6 +46,18 @@ export interface SlicetestOptions {
44
46
  * scenarios read what arrived with `mail.messages()` / `mail.waitFor()`.
45
47
  */
46
48
  mail?: boolean;
49
+ /**
50
+ * Keep the app off the network: its HTTP(S) calls may only reach localhost and the
51
+ * hosts of stubs with `hosts`. A call anywhere else is refused and fails the
52
+ * scenario, naming the host, so a forgotten stub can't reach a real service.
53
+ */
54
+ offline?: boolean;
55
+ /**
56
+ * Most Vitest workers to run test files in (Vitest's `maxWorkers`). Each worker has
57
+ * its own app and database; with `app.scope: "worker"`, fewer workers means fewer app
58
+ * starts, which is what makes a slow-starting app fast to test.
59
+ */
60
+ workers?: number;
47
61
  /**
48
62
  * An OpenID Connect issuer for apps that verify JWTs. The app gets
49
63
  * `{{auth.issuer}}`, `{{auth.jwks}}` and `{{auth.audience}}`; scenarios mint
@@ -79,6 +93,12 @@ export interface StubOptions {
79
93
  upstream?: string;
80
94
  /** Recordings file, relative to the root. Default `recordings/<name>.yaml`. */
81
95
  recordings?: string;
96
+ /**
97
+ * Hosts the app calls directly, e.g. `["api.github.com"]`: their HTTP and HTTPS
98
+ * traffic is answered by this stub, for apps whose URLs can't be set from the
99
+ * environment. The app is started with proxy variables and a test CA it trusts.
100
+ */
101
+ hosts?: string[];
82
102
  }
83
103
  export interface ServiceOptions extends Omit<AppOptions, "ready"> {
84
104
  /** Default: no wait (for workers that don't listen). `{ path }` polls the service's own port. */
@@ -91,12 +111,20 @@ type ResolvedReady = {
91
111
  flags: string;
92
112
  };
93
113
  /** A process to start, with `ready` made JSON-serializable. The app always has `ready`; services may not. */
94
- export type ResolvedProcess = Omit<AppOptions, "ready"> & {
114
+ export type ResolvedProcess = Omit<AppOptions, "ready" | "scope"> & {
95
115
  ready?: ResolvedReady;
116
+ /** Set by slicetest (proxy variables for intercepted hosts); `env` overrides it. */
117
+ baseEnv?: Record<string, string>;
96
118
  };
97
119
  export interface AppOptions {
98
120
  /** Command that starts the app, run through the shell. */
99
121
  command: string;
122
+ /**
123
+ * Command run once per run, before any worker starts the process, e.g.
124
+ * `npm run build` for an app started with `next start`. Runs in `cwd` while
125
+ * the database starts. Not repeated on re-runs in watch mode.
126
+ */
127
+ build?: string;
100
128
  cwd?: string;
101
129
  /**
102
130
  * Environment passed to the app. Values may reference
@@ -112,6 +140,13 @@ export interface AppOptions {
112
140
  };
113
141
  /** Milliseconds to wait for readiness. Default 30000. */
114
142
  readyTimeout?: number;
143
+ /**
144
+ * `"file"` (default) starts the app, stubs and services for each test file.
145
+ * `"worker"` starts them once per Vitest worker and keeps them for every file that
146
+ * worker runs, for apps that take seconds to start (a JVM, a large framework). It
147
+ * turns off Vitest's per-file module isolation (`isolate: false`).
148
+ */
149
+ scope?: "file" | "worker";
115
150
  }
116
151
  export interface DbOptions {
117
152
  /**
@@ -148,6 +183,12 @@ export interface DbOptions {
148
183
  * statement, whatever the app's language or driver. Off by default.
149
184
  */
150
185
  queries?: boolean;
186
+ /**
187
+ * For apps on Neon's serverless driver over HTTP (`neon()` from @neondatabase/serverless,
188
+ * drizzle-orm/neon-http): `{{db.url}}` becomes a Neon-style connection string, and
189
+ * slicetest answers the driver's HTTP queries from the test database. Postgres only.
190
+ */
191
+ neon?: boolean;
151
192
  }
152
193
  export type MigrateOptions = {
153
194
  atlas: {
@@ -167,18 +208,26 @@ export type MigrateOptions = {
167
208
  /** Normalized shape passed from the plugin to globalSetup and workers. Must stay JSON-serializable. */
168
209
  export interface ResolvedOptions {
169
210
  root: string;
170
- app: Omit<AppOptions, "ready"> & {
211
+ app: ResolvedProcess & {
171
212
  ready: ResolvedReady;
213
+ scope?: "file" | "worker";
172
214
  };
173
215
  services: Record<string, ResolvedProcess>;
174
216
  containers: Record<string, ContainerOptions>;
175
217
  mail: boolean;
218
+ offline: boolean;
219
+ workers?: number;
176
220
  auth: AuthOptions | false;
177
- db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">;
221
+ db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse"> & {
222
+ /** `db: false`: the app has no database. */
223
+ none?: boolean;
224
+ };
178
225
  stubs: string[];
179
226
  /** Spec files, resolved against the root: the app's, and per stub name. */
227
+ /** `fromApp`: a path the running app serves its own spec at (springdoc, FastAPI, NestJS). */
180
228
  openapi: {
181
229
  app?: string;
230
+ fromApp?: string;
182
231
  minCoverage?: number;
183
232
  stubs: Record<string, string>;
184
233
  autoReply: string[];
@@ -189,7 +238,11 @@ export interface ResolvedOptions {
189
238
  upstream: string;
190
239
  record: boolean;
191
240
  }>;
241
+ /** Intercepted host (lower case) → the stub that answers it. */
242
+ intercept: Record<string, string>;
192
243
  http?: RequestOptions;
193
244
  }
245
+ /** The config files `npx slicetest` (and `slicetest()` without options) look for, in order. */
246
+ export declare const CONFIG_NAMES: string[];
194
247
  export declare function resolveOptions(opts: SlicetestOptions, root: string): ResolvedOptions;
195
248
  export {};