slicetest 0.5.0 → 0.6.1
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 +183 -5
- package/dist/app.js +39 -11
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +13 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +10 -3
- package/dist/config.d.ts +58 -5
- package/dist/config.js +61 -9
- package/dist/containers.d.ts +2 -0
- package/dist/containers.js +20 -1
- package/dist/db.d.ts +2 -0
- package/dist/db.js +18 -0
- package/dist/doctor.js +17 -5
- package/dist/form.d.ts +45 -0
- package/dist/form.js +226 -0
- package/dist/global-setup.d.ts +1 -0
- package/dist/global-setup.js +90 -34
- package/dist/http.d.ts +19 -1
- package/dist/http.js +130 -22
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/init.js +203 -25
- package/dist/intercept.d.ts +37 -0
- package/dist/intercept.js +199 -0
- package/dist/neon.d.ts +14 -0
- package/dist/neon.js +113 -0
- package/dist/openapi.d.ts +2 -0
- package/dist/openapi.js +4 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +127 -25
- package/dist/setup-file.js +19 -2
- package/dist/stub.d.ts +14 -0
- package/dist/stub.js +22 -0
- package/dist/vitest.d.ts +11 -1
- package/dist/vitest.js +27 -2
- package/dist/x509.d.ts +37 -0
- package/dist/x509.js +150 -0
- package/dist/yaml-runtime.js +35 -13
- package/dist/yaml.d.ts +32 -1
- package/dist/yaml.js +31 -1
- package/package.json +5 -3
- package/preload/node-proxy.cjs +19 -0
- package/schema/scenario.schema.json +140 -0
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. Cookies follow their `Path` as in browsers.
|
|
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.
|
|
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
|
-
| `
|
|
557
|
-
| `
|
|
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
|
@@ -66,14 +66,27 @@ export class App {
|
|
|
66
66
|
* `service.<name>` for a service (its port is then `{{service.<name>.port}}`).
|
|
67
67
|
*/
|
|
68
68
|
static async start(opts, root, vars, key = "app", fixedPort) {
|
|
69
|
+
// A free port can be taken by someone else before the app binds it (another worker, another
|
|
70
|
+
// program). When that is why the app died, try again on a fresh port.
|
|
71
|
+
for (let attempt = 1;; attempt++) {
|
|
72
|
+
try {
|
|
73
|
+
return await App.#start(opts, root, vars, key, fixedPort);
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
if (fixedPort !== undefined || attempt >= PORT_ATTEMPTS || !(e instanceof PortInUse))
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
static async #start(opts, root, vars, key, fixedPort) {
|
|
69
82
|
// A restarted service keeps its port, so URLs already handed to other processes stay valid.
|
|
70
83
|
const port = fixedPort ?? (await freePort());
|
|
71
84
|
vars = { ...vars, [`${key}.port`]: String(port) };
|
|
72
|
-
const env = opts.env ?? { PORT: `{{${key}.port}}`, DATABASE_URL: "{{db.url}}" };
|
|
85
|
+
const env = opts.env ?? { PORT: `{{${key}.port}}`, ...("db.url" in vars ? { DATABASE_URL: "{{db.url}}" } : {}) };
|
|
73
86
|
const child = spawn(interpolate(opts.command, vars, `${key}.command`), {
|
|
74
87
|
shell: true,
|
|
75
88
|
cwd: path.resolve(root, opts.cwd ?? "."),
|
|
76
|
-
env: { ...process.env, ...mapValues(env, (v) => interpolate(v, vars, `${key}.env`)) },
|
|
89
|
+
env: { ...process.env, ...opts.baseEnv, ...mapValues(env, (v) => interpolate(v, vars, `${key}.env`)) },
|
|
77
90
|
stdio: ["ignore", "pipe", "pipe"],
|
|
78
91
|
// POSIX: own process group, so stop() also kills whatever the shell spawned.
|
|
79
92
|
// Windows: detaching would open a console window; taskkill /T walks the tree instead.
|
|
@@ -87,6 +100,8 @@ export class App {
|
|
|
87
100
|
}
|
|
88
101
|
catch (e) {
|
|
89
102
|
await app.stop();
|
|
103
|
+
if (app.#exit && ADDRESS_IN_USE.test(app.logs()))
|
|
104
|
+
throw new PortInUse(e.message);
|
|
90
105
|
throw e;
|
|
91
106
|
}
|
|
92
107
|
return app;
|
|
@@ -266,14 +281,27 @@ function escapeRegExp(s) {
|
|
|
266
281
|
function mapValues(obj, fn) {
|
|
267
282
|
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, fn(v)]));
|
|
268
283
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
284
|
+
const PORT_ATTEMPTS = 3;
|
|
285
|
+
/** How Node, Python, Go, Ruby, the JVM and others report a port that is taken. */
|
|
286
|
+
const ADDRESS_IN_USE = /EADDRINUSE|address already in use|Address in use|BindException/i;
|
|
287
|
+
class PortInUse extends Error {
|
|
288
|
+
}
|
|
289
|
+
/** Ports this process has handed out; the OS may offer one again before the app has bound it. */
|
|
290
|
+
const handedOut = new Set();
|
|
291
|
+
async function freePort() {
|
|
292
|
+
for (;;) {
|
|
293
|
+
const port = await new Promise((resolve, reject) => {
|
|
294
|
+
const srv = createServer();
|
|
295
|
+
srv.unref();
|
|
296
|
+
srv.on("error", reject);
|
|
297
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
298
|
+
const { port } = srv.address();
|
|
299
|
+
srv.close(() => resolve(port));
|
|
300
|
+
});
|
|
277
301
|
});
|
|
278
|
-
|
|
302
|
+
if (!handedOut.has(port)) {
|
|
303
|
+
handedOut.add(port);
|
|
304
|
+
return port;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
279
307
|
}
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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 {};
|