slicetest 0.1.0 → 0.2.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 +151 -11
- package/dist/app.d.ts +26 -3
- package/dist/app.js +80 -18
- package/dist/cli.js +18 -6
- package/dist/config.d.ts +68 -9
- package/dist/config.js +58 -22
- package/dist/db.d.ts +37 -5
- package/dist/db.js +149 -81
- package/dist/drivers/driver.d.ts +65 -0
- package/dist/drivers/driver.js +6 -0
- package/dist/drivers/index.d.ts +5 -0
- package/dist/drivers/index.js +5 -0
- package/dist/drivers/postgres.d.ts +19 -0
- package/dist/drivers/postgres.js +164 -0
- package/dist/global-setup.d.ts +7 -1
- package/dist/global-setup.js +132 -30
- package/dist/http.d.ts +3 -0
- package/dist/http.js +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/init.d.ts +18 -0
- package/dist/init.js +134 -0
- package/dist/openapi.d.ts +56 -0
- package/dist/openapi.js +305 -0
- package/dist/provided.d.ts +1 -0
- package/dist/runtime.d.ts +7 -1
- package/dist/runtime.js +163 -28
- package/dist/scenario.js +2 -2
- package/dist/stub.d.ts +14 -0
- package/dist/stub.js +34 -9
- package/dist/yaml-runtime.js +61 -1
- package/dist/yaml.d.ts +30 -1
- package/dist/yaml.js +39 -4
- package/package.json +8 -4
- package/schema/scenario.schema.json +132 -1
package/README.md
CHANGED
|
@@ -27,10 +27,27 @@ No browser, no mocked database, no hooks inside your app. The app only has to re
|
|
|
27
27
|
|
|
28
28
|
- **Unit tests** mock the database and the network, so broken SQL, migrations and request payloads slip through.
|
|
29
29
|
- **End-to-end tests** drive a browser against a deployed stack. They are slow and hard to make deterministic.
|
|
30
|
-
- **slicetest** keeps the real HTTP server, the real SQL and the real migrations, and replaces only the things you don't own: third-party APIs.
|
|
30
|
+
- **slicetest** keeps the real HTTP server, the real SQL and the real migrations, and replaces only the things you don't own: third-party APIs. With OpenAPI specs, it also checks that those replacements behave like the real thing.
|
|
31
31
|
|
|
32
32
|
The database is reset between scenarios with a single `TRUNCATE ... RESTART IDENTITY CASCADE` (about 1.5 ms). The app keeps its connections, so this works with any driver or ORM. Resetting by dropping and re-creating the database takes about 130 ms, and it crashed some apps when their pooled connections were cut.
|
|
33
33
|
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npx slicetest init # detects your stack, writes slicetest.config.yaml and a first scenario
|
|
38
|
+
npx slicetest # starts Postgres, migrates, starts your app, runs scenarios/*.scenario.yaml
|
|
39
|
+
```
|
|
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`. It lists every guess as a comment in the config so you know what to check.
|
|
42
|
+
|
|
43
|
+
## What you get that's hard to find elsewhere
|
|
44
|
+
|
|
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
|
+
- **`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.
|
|
48
|
+
- **OpenAPI coverage** of your own API, per operation and status, across all scenarios.
|
|
49
|
+
- **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.
|
|
50
|
+
|
|
34
51
|
## Install
|
|
35
52
|
|
|
36
53
|
```sh
|
|
@@ -73,11 +90,11 @@ export default defineConfig({
|
|
|
73
90
|
|
|
74
91
|
### How a run works
|
|
75
92
|
|
|
76
|
-
1. **Once per run.** slicetest starts `postgres:17-alpine` and migrates a template database.
|
|
93
|
+
1. **Once per run.** slicetest starts `postgres:17-alpine` and migrates a template database. Locally, the container is kept running and the migrated template is cached by the contents of your migrations, so the next run with unchanged migrations skips both steps (see `db.reuse`).
|
|
77
94
|
2. **Once per worker.** It clones the template into the worker's own database.
|
|
78
|
-
3. **Once per test file.** It starts the stub servers and your app.
|
|
79
|
-
4. **Before each scenario.** It truncates every table except migration bookkeeping tables (`atlas_schema_revisions`, `_prisma_migrations`, `alembic_version`, `django_migrations`, …) and extension-owned tables such as PostGIS's `spatial_ref_sys`, re-runs the seed, and clears the stubs, cookies and request history. If the app crashed in the previous scenario, it is restarted.
|
|
80
|
-
5. **After each scenario.** The scenario fails if the app crashed
|
|
95
|
+
3. **Once per test file.** It starts the stub servers, your `services` and your app.
|
|
96
|
+
4. **Before each scenario.** It truncates every table except migration bookkeeping tables (`atlas_schema_revisions`, `_prisma_migrations`, `alembic_version`, `django_migrations`, …) and extension-owned tables such as PostGIS's `spatial_ref_sys`, re-runs the seed, and clears the stubs, cookies and request history. If the app or a service crashed in the previous scenario, it is restarted.
|
|
97
|
+
5. **After each scenario.** The scenario fails if the app or a service crashed, the app called a stub route you didn't register, or (with `openapi`) any traffic didn't match the spec.
|
|
81
98
|
|
|
82
99
|
Database names are unique per run, so several projects or CI jobs can share one Postgres server via `db.url`.
|
|
83
100
|
|
|
@@ -94,16 +111,22 @@ stub calls with no matching route:
|
|
|
94
111
|
requests to the app:
|
|
95
112
|
POST /signup → 500 (14ms) {"error":"internal"}
|
|
96
113
|
|
|
114
|
+
database changes during this scenario:
|
|
115
|
+
users: 1 inserted
|
|
116
|
+
+ {"id":1,"email":"a@example.com","verified":false}
|
|
117
|
+
audit_log: 1 updated
|
|
118
|
+
~ id=7 status: "pending" → "failed"
|
|
119
|
+
|
|
97
120
|
app output during this scenario:
|
|
98
121
|
TypeError: Cannot read properties of undefined (reading 'email')
|
|
99
122
|
-----------------
|
|
100
123
|
```
|
|
101
124
|
|
|
102
|
-
Only this scenario's app output is shown, not the whole log. Requests that never got a response (for example because the app crashed) appear as `failed`.
|
|
125
|
+
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`.
|
|
103
126
|
|
|
104
127
|
## API
|
|
105
128
|
|
|
106
|
-
Every scenario receives `{ http, db, stub, app }`.
|
|
129
|
+
Every scenario receives `{ http, db, stub, app, service }`.
|
|
107
130
|
|
|
108
131
|
### `http` — talk to the app
|
|
109
132
|
|
|
@@ -132,7 +155,25 @@ await db.sql`SELECT * FROM users WHERE id = ${user.id}`; // values beco
|
|
|
132
155
|
await db.query("UPDATE users SET name = $1", ["b"]);
|
|
133
156
|
```
|
|
134
157
|
|
|
135
|
-
In `where`, `null` means `IS NULL` and an array means `IN (...)`.
|
|
158
|
+
In `where`, `null` means `IS NULL` and an array means `IN (...)`.
|
|
159
|
+
|
|
160
|
+
#### `db.changes()` — assert on everything the app wrote
|
|
161
|
+
|
|
162
|
+
Instead of guessing which tables to query, ask for the diff. Rows are matched by primary key, so updates show which columns changed:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
await db.insert("users", { email: "a@example.com" });
|
|
166
|
+
await db.checkpoint(); // ignore what the test itself arranged
|
|
167
|
+
|
|
168
|
+
await http.post("/users/1/verify");
|
|
169
|
+
|
|
170
|
+
expect(await db.changes()).toEqual({
|
|
171
|
+
users: { inserted: [], deleted: [], updated: [expect.objectContaining({ changed: ["verified"] })] },
|
|
172
|
+
audit_log: { inserted: [expect.objectContaining({ action: "verify" })], updated: [], deleted: [] },
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`toEqual` fails if the app wrote to a table you didn't list, which catches unexpected side effects. Each entry in `updated` has `key`, `before`, `after` and `changed`. Tables without a primary key report an update as one deleted row plus one inserted row. `bigint` columns (bigserial ids, `count(*)`) come back as numbers when they fit safely.
|
|
136
177
|
|
|
137
178
|
### `stub(name)` — fake the services the app calls
|
|
138
179
|
|
|
@@ -158,6 +199,53 @@ stub("slack").calls("POST", "/hook"); // recorded calls:
|
|
|
158
199
|
|
|
159
200
|
Later routes win. `path` may also be a RegExp, and `method` may be `*`. Unanswered calls get a `501` and fail the scenario.
|
|
160
201
|
|
|
202
|
+
### OpenAPI contracts — for your app and for the services you stub
|
|
203
|
+
|
|
204
|
+
Point slicetest at OpenAPI 3.0 / 3.1 files and every scenario doubles as a contract test, with no extra assertions:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
slicetest({
|
|
208
|
+
openapi: "openapi.yaml", // your app's spec
|
|
209
|
+
stubs: ["slack", { name: "stripe", openapi: "specs/stripe.yaml" }], // a provider's spec
|
|
210
|
+
// ...
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
- **Your app's responses** must be documented (path, method, status) and match the schema.
|
|
215
|
+
- **The app's requests to a stub** must match the provider's spec: required query parameters, content type and request body. Spec paths are matched with or without the server's base path (`/v1`).
|
|
216
|
+
- **Your stubs' replies** must be something the real service could send. A stub that returns `200 { ok: true }` where the provider documents `202 { messageId }` makes tests pass against an API that doesn't exist; slicetest fails the scenario instead.
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
slicetest: traffic doesn't match the OpenAPI spec:
|
|
220
|
+
app: GET /users/{id} → 200: /id must be integer
|
|
221
|
+
app → mail: POST /mail/send request: body must have required property 'subject'
|
|
222
|
+
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)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
#### `autoReply`: stubs generated from the provider's spec
|
|
226
|
+
|
|
227
|
+
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.
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
stubs: [{ name: "stripe", openapi: "specs/stripe.yaml", autoReply: true }],
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Calls answered this way have `call.fallback === true`. Paths that aren't in the spec still get a 501 and fail the scenario.
|
|
234
|
+
|
|
235
|
+
At the end of the run, slicetest prints which documented responses your scenarios actually produced, merged across workers and across TypeScript and YAML scenarios:
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
slicetest: OpenAPI coverage (openapi.yaml): 8/9 documented responses (89%)
|
|
239
|
+
GET /health 200 ✓
|
|
240
|
+
POST /polls 201 ✓ 400 ✓ 502 ✓
|
|
241
|
+
GET /polls/{id} 200 ✓ 404 ✗
|
|
242
|
+
POST /polls/{id}/votes 204 ✓ 400 ✓ 404 ✓
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
To fail the run below a threshold, use `openapi: { spec: "openapi.yaml", minCoverage: 100 }`. Filtered runs (`-t`, a single file) count too, so you may want `minCoverage: process.env.CI ? 100 : undefined`.
|
|
246
|
+
|
|
247
|
+
The example apps in `examples/` run every scenario against `examples/openapi.yaml` with `minCoverage: 100`, and their Slack calls against `examples/slack.openapi.yaml`.
|
|
248
|
+
|
|
161
249
|
### Matchers
|
|
162
250
|
|
|
163
251
|
Registered automatically:
|
|
@@ -173,6 +261,47 @@ await expect(db).toHaveRow("votes", { poll_id: 1 }, 3); // exactly thre
|
|
|
173
261
|
|
|
174
262
|
Failure messages list the calls the stub actually received, or the first rows of the table.
|
|
175
263
|
|
|
264
|
+
### Services: workers and other processes
|
|
265
|
+
|
|
266
|
+
Real apps are rarely one process. Declare the others under `services` and slicetest starts them before the app (in order), watches them like the app, and restarts one that crashed — on the same port, so URLs handed to other processes stay valid:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
slicetest({
|
|
270
|
+
services: {
|
|
271
|
+
pricing: { command: "go run ./cmd/pricing", ready: { path: "/health" } }, // another HTTP service
|
|
272
|
+
worker: { command: "bundle exec sidekiq" }, // no port, no ready check
|
|
273
|
+
},
|
|
274
|
+
app: {
|
|
275
|
+
command: "node server.js",
|
|
276
|
+
env: { PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}", PRICING_URL: "{{service.pricing}}" },
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Each service gets `PORT` = `{{service.<name>.port}}` and `DATABASE_URL` unless you pass `env`. A crash fails the scenario, and each service's output during the scenario is part of the failure output.
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
scenario("uploading an image queues a thumbnail job", async ({ http, db, service }) => {
|
|
285
|
+
await http.post("/images", { url: "https://example.com/cat.png" });
|
|
286
|
+
|
|
287
|
+
await service("worker").waitForLog(/thumbnail \d+ done/); // this scenario's output only
|
|
288
|
+
await expect(db).toHaveRow("images", { thumbnail_ready: true });
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`waitForLog(pattern, timeout = 5000)` resolves with the matching line and also works on `app`. In YAML: `- log: thumbnail \d+ done` with `from: worker` and `within: <ms>`.
|
|
293
|
+
|
|
294
|
+
### Asynchronous side effects
|
|
295
|
+
|
|
296
|
+
If the app does work in the background (a job queue, a fire-and-forget webhook), wait for the effect with Vitest's own helpers. slicetest doesn't need its own:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
await vi.waitFor(() => expect(stub("mail")).toHaveReceived("POST", "/send"));
|
|
300
|
+
await expect.poll(() => db.count("jobs", { status: "done" })).toBe(1);
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
In YAML, add `within: <ms>` to a `db`, `sql`, `received` or `changes` step.
|
|
304
|
+
|
|
176
305
|
### Scenarios
|
|
177
306
|
|
|
178
307
|
```ts
|
|
@@ -195,13 +324,16 @@ Scenarios in one file share an app and a database, so they always run one at a t
|
|
|
195
324
|
| `app.cwd` | vitest root | |
|
|
196
325
|
| `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
|
|
197
326
|
| `app.readyTimeout` | `30000` | |
|
|
198
|
-
| `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command }` (gets `DATABASE_URL`). |
|
|
327
|
+
| `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs? }` (gets `DATABASE_URL`). |
|
|
199
328
|
| `db.seed` | none | SQL file re-run after every reset. |
|
|
200
329
|
| `db.schemas` | `["public"]` | Schemas whose tables are reset. |
|
|
201
330
|
| `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
|
|
202
331
|
| `db.url` | `$SLICETEST_DATABASE_URL`, else a container | Use an existing Postgres server (e.g. a CI service container) instead of Testcontainers. |
|
|
203
332
|
| `db.image` | `postgres:17-alpine` | |
|
|
204
|
-
| `
|
|
333
|
+
| `db.reuse` | on, unless `CI` is set or `db.url` is given | Keep the container between runs and cache the migrated template. The cache key is the migration files' contents; for `{ command }`, list what it reads in `inputs: ["prisma/migrations"]`, or it migrates every run. Databases left by killed runs are dropped after a day. Remove the container (`docker rm -f` / `podman rm -f`) to start clean. |
|
|
334
|
+
| `services` | `{}` | Other processes: `{ name: { command, env?, cwd?, ready?, readyTimeout? } }`. Without `ready` a service is not waited for. |
|
|
335
|
+
| `stubs` | `[]` | Names of stubbed services, or `{ name, openapi }` to check calls and replies against the provider's spec. |
|
|
336
|
+
| `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`. Every response must match it; the run ends with a coverage report. |
|
|
205
337
|
| `http` | `{}` | Default `headers` / `query` for every request. |
|
|
206
338
|
|
|
207
339
|
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.
|
|
@@ -237,6 +369,9 @@ scenarios:
|
|
|
237
369
|
when:
|
|
238
370
|
json: { text: "New poll: Dogs or cats?" }
|
|
239
371
|
|
|
372
|
+
- changes: # and nothing else was written
|
|
373
|
+
polls: { inserted: 1 }
|
|
374
|
+
|
|
240
375
|
- name: voting {{choice}} returns {{status}}
|
|
241
376
|
each:
|
|
242
377
|
- { choice: a, status: 204 }
|
|
@@ -255,6 +390,11 @@ scenarios:
|
|
|
255
390
|
| `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
|
|
256
391
|
| `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
|
|
257
392
|
| `received: <stub>` | `call: METHOD /path`, `when`, `times` (exact; default at least once) |
|
|
393
|
+
| `log: <regex>` | `from` (a service; default the app), `within` (ms, default 5000). Waits for a matching line printed during the scenario. |
|
|
394
|
+
| `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. |
|
|
395
|
+
| `checkpoint: true` | Later `changes` steps only see what happens after this step. |
|
|
396
|
+
|
|
397
|
+
`db`, `sql`, `received` and `changes` steps take `within: <ms>` to retry until they pass, for effects the app applies asynchronously.
|
|
258
398
|
|
|
259
399
|
- `{{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.
|
|
260
400
|
- Expected `json`, `rows` and `headers` are subsets: extra keys are fine. `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "..." }` and `{ $any: true }` match loosely.
|
|
@@ -293,4 +433,4 @@ npm run test:dist # the built package, and the CLI with examples/slicetest.con
|
|
|
293
433
|
|
|
294
434
|
## Status
|
|
295
435
|
|
|
296
|
-
Early
|
|
436
|
+
Early. Postgres only. CI runs on Linux and Windows. Planned: MySQL.
|
package/dist/app.d.ts
CHANGED
|
@@ -1,24 +1,47 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ResolvedProcess } from "./config.js";
|
|
2
|
+
type ProcessOptions = ResolvedProcess;
|
|
2
3
|
type Exit = {
|
|
3
4
|
code: number | null;
|
|
4
5
|
signal: NodeJS.Signals | null;
|
|
5
6
|
error?: Error;
|
|
6
7
|
};
|
|
7
|
-
/** The app under test, running as a real child process. */
|
|
8
|
+
/** The app under test (or one of its `services`), running as a real child process. */
|
|
8
9
|
export declare class App {
|
|
9
10
|
#private;
|
|
11
|
+
readonly port: number;
|
|
12
|
+
/** `app`, or `service <name>`, for messages. */
|
|
13
|
+
readonly label: string;
|
|
10
14
|
readonly url: string;
|
|
11
15
|
private constructor();
|
|
12
|
-
|
|
16
|
+
/**
|
|
17
|
+
* `key` names the process in placeholders and messages: `app`, or
|
|
18
|
+
* `service.<name>` for a service (its port is then `{{service.<name>.port}}`).
|
|
19
|
+
*/
|
|
20
|
+
static start(opts: ProcessOptions, root: string, vars: Record<string, string>, key?: string, fixedPort?: number): Promise<App>;
|
|
13
21
|
get exited(): Exit | undefined;
|
|
14
22
|
/**
|
|
15
23
|
* The exit event is delivered asynchronously, so a crash caused by the last
|
|
16
24
|
* request may not be visible yet. Give it a moment before trusting `exited`.
|
|
25
|
+
* Windows reports it later (the process runs under cmd.exe), so it waits longer.
|
|
17
26
|
*/
|
|
18
27
|
settle(ms?: number): Promise<Exit | undefined>;
|
|
19
28
|
/** The app's recent output (last 200 lines), or only what it printed after `since` (a value from `mark()`). */
|
|
20
29
|
logs(since?: number): string;
|
|
21
30
|
mark(): number;
|
|
31
|
+
/** What the process printed during the current scenario. */
|
|
32
|
+
scenarioLogs(): string;
|
|
33
|
+
/** Called before each scenario; `waitForLog()` only looks at output after this point. */
|
|
34
|
+
beginScenario(): number;
|
|
35
|
+
/**
|
|
36
|
+
* Wait until the process prints a line matching `pattern` during the current
|
|
37
|
+
* scenario (lines printed earlier in the scenario count). Resolves with the line.
|
|
38
|
+
*
|
|
39
|
+
* ```ts
|
|
40
|
+
* await http.post("/orders", { ... });
|
|
41
|
+
* await service("worker").waitForLog(/order \d+ shipped/);
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
waitForLog(pattern: string | RegExp, timeout?: number): Promise<string>;
|
|
22
45
|
/**
|
|
23
46
|
* POSIX: SIGTERM the whole process group, then SIGKILL whatever is left (including orphaned grandchildren).
|
|
24
47
|
* Windows has no signals to ask politely with, so the tree is terminated at once.
|
package/dist/app.js
CHANGED
|
@@ -7,19 +7,27 @@ const LOG_LINES = 200;
|
|
|
7
7
|
const DRAIN_MS = 100;
|
|
8
8
|
const KILL_GRACE_MS = 3000;
|
|
9
9
|
const WINDOWS = process.platform === "win32";
|
|
10
|
-
/** The app under test, running as a real child process. */
|
|
10
|
+
/** The app under test (or one of its `services`), running as a real child process. */
|
|
11
11
|
export class App {
|
|
12
|
-
|
|
12
|
+
port;
|
|
13
|
+
label;
|
|
13
14
|
#child;
|
|
14
15
|
#log = [];
|
|
15
16
|
/** Lines ever received, so callers can ask for output since a point in time. */
|
|
16
17
|
#lineCount = 0;
|
|
17
18
|
#exit;
|
|
18
19
|
#exited;
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
#listeners = new Set();
|
|
21
|
+
/** `mark()` at the start of the current scenario. */
|
|
22
|
+
#scenarioMark = 0;
|
|
23
|
+
url;
|
|
24
|
+
constructor(child, port,
|
|
25
|
+
/** `app`, or `service <name>`, for messages. */
|
|
26
|
+
label) {
|
|
27
|
+
this.port = port;
|
|
28
|
+
this.label = label;
|
|
22
29
|
this.#child = child;
|
|
30
|
+
this.url = `http://127.0.0.1:${port}`;
|
|
23
31
|
for (const stream of [child.stdout, child.stderr]) {
|
|
24
32
|
// Keep partial lines (and split multi-byte characters) until the rest arrives.
|
|
25
33
|
const decoder = new StringDecoder("utf8");
|
|
@@ -31,7 +39,8 @@ export class App {
|
|
|
31
39
|
this.#lineCount++;
|
|
32
40
|
if (this.#log.length > LOG_LINES)
|
|
33
41
|
this.#log.shift();
|
|
34
|
-
this.#
|
|
42
|
+
for (const listener of this.#listeners)
|
|
43
|
+
listener(line);
|
|
35
44
|
};
|
|
36
45
|
stream.on("data", (chunk) => {
|
|
37
46
|
const lines = (partial + decoder.write(chunk)).split(/\r?\n/);
|
|
@@ -52,21 +61,26 @@ export class App {
|
|
|
52
61
|
child.on("close", (code, signal) => done({ code, signal }));
|
|
53
62
|
});
|
|
54
63
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
/**
|
|
65
|
+
* `key` names the process in placeholders and messages: `app`, or
|
|
66
|
+
* `service.<name>` for a service (its port is then `{{service.<name>.port}}`).
|
|
67
|
+
*/
|
|
68
|
+
static async start(opts, root, vars, key = "app", fixedPort) {
|
|
69
|
+
// A restarted service keeps its port, so URLs already handed to other processes stay valid.
|
|
70
|
+
const port = fixedPort ?? (await freePort());
|
|
71
|
+
vars = { ...vars, [`${key}.port`]: String(port) };
|
|
72
|
+
const env = opts.env ?? { PORT: `{{${key}.port}}`, DATABASE_URL: "{{db.url}}" };
|
|
73
|
+
const child = spawn(interpolate(opts.command, vars, `${key}.command`), {
|
|
60
74
|
shell: true,
|
|
61
75
|
cwd: path.resolve(root, opts.cwd ?? "."),
|
|
62
|
-
env: { ...process.env, ...mapValues(env, (v) => interpolate(v, vars,
|
|
76
|
+
env: { ...process.env, ...mapValues(env, (v) => interpolate(v, vars, `${key}.env`)) },
|
|
63
77
|
stdio: ["ignore", "pipe", "pipe"],
|
|
64
78
|
// POSIX: own process group, so stop() also kills whatever the shell spawned.
|
|
65
79
|
// Windows: detaching would open a console window; taskkill /T walks the tree instead.
|
|
66
80
|
detached: !WINDOWS,
|
|
67
81
|
windowsHide: true,
|
|
68
82
|
});
|
|
69
|
-
const app = new App(child,
|
|
83
|
+
const app = new App(child, port, key === "app" ? "app" : key.replace(/^service\./, "service "));
|
|
70
84
|
running.add(app);
|
|
71
85
|
try {
|
|
72
86
|
await app.#waitReady(opts);
|
|
@@ -83,8 +97,9 @@ export class App {
|
|
|
83
97
|
/**
|
|
84
98
|
* The exit event is delivered asynchronously, so a crash caused by the last
|
|
85
99
|
* request may not be visible yet. Give it a moment before trusting `exited`.
|
|
100
|
+
* Windows reports it later (the process runs under cmd.exe), so it waits longer.
|
|
86
101
|
*/
|
|
87
|
-
async settle(ms = 20) {
|
|
102
|
+
async settle(ms = WINDOWS ? 100 : 20) {
|
|
88
103
|
if (this.#exit)
|
|
89
104
|
return this.#exit;
|
|
90
105
|
let timer;
|
|
@@ -102,6 +117,44 @@ export class App {
|
|
|
102
117
|
mark() {
|
|
103
118
|
return this.#lineCount;
|
|
104
119
|
}
|
|
120
|
+
/** What the process printed during the current scenario. */
|
|
121
|
+
scenarioLogs() {
|
|
122
|
+
return this.logs(this.#scenarioMark);
|
|
123
|
+
}
|
|
124
|
+
/** Called before each scenario; `waitForLog()` only looks at output after this point. */
|
|
125
|
+
beginScenario() {
|
|
126
|
+
this.#scenarioMark = this.#lineCount;
|
|
127
|
+
return this.#scenarioMark;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Wait until the process prints a line matching `pattern` during the current
|
|
131
|
+
* scenario (lines printed earlier in the scenario count). Resolves with the line.
|
|
132
|
+
*
|
|
133
|
+
* ```ts
|
|
134
|
+
* await http.post("/orders", { ... });
|
|
135
|
+
* await service("worker").waitForLog(/order \d+ shipped/);
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
async waitForLog(pattern, timeout = 5000) {
|
|
139
|
+
const re = typeof pattern === "string" ? new RegExp(escapeRegExp(pattern)) : new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
|
|
140
|
+
const n = Math.min(this.#lineCount - this.#scenarioMark, this.#log.length);
|
|
141
|
+
const seen = n > 0 ? this.#log.slice(-n).find((l) => re.test(l)) : undefined;
|
|
142
|
+
if (seen !== undefined)
|
|
143
|
+
return seen;
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const done = (fn) => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
this.#listeners.delete(listener);
|
|
148
|
+
fn();
|
|
149
|
+
};
|
|
150
|
+
const listener = (line) => {
|
|
151
|
+
if (re.test(line))
|
|
152
|
+
done(() => resolve(line));
|
|
153
|
+
};
|
|
154
|
+
const timer = setTimeout(() => done(() => reject(new Error(`slicetest: ${this.label} printed no line matching ${re} within ${timeout}ms. Output during this scenario:\n${this.logs(this.#scenarioMark) || "(none)"}`))), timeout);
|
|
155
|
+
this.#listeners.add(listener);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
105
158
|
/**
|
|
106
159
|
* POSIX: SIGTERM the whole process group, then SIGKILL whatever is left (including orphaned grandchildren).
|
|
107
160
|
* Windows has no signals to ask politely with, so the tree is terminated at once.
|
|
@@ -139,18 +192,23 @@ export class App {
|
|
|
139
192
|
const timeout = opts.readyTimeout ?? 30_000;
|
|
140
193
|
const deadline = Date.now() + timeout;
|
|
141
194
|
const ready = opts.ready;
|
|
195
|
+
// A service without `ready` (e.g. a queue worker) counts as ready once it's spawned.
|
|
196
|
+
if (!ready)
|
|
197
|
+
return;
|
|
142
198
|
let logSeen = false;
|
|
199
|
+
let onLine;
|
|
143
200
|
if ("log" in ready) {
|
|
144
201
|
// Drop g/y so test() doesn't keep state between lines.
|
|
145
202
|
const pattern = new RegExp(ready.log, ready.flags.replace(/[gy]/g, ""));
|
|
146
203
|
logSeen = this.#log.some((l) => pattern.test(l));
|
|
147
|
-
|
|
204
|
+
onLine = (line) => (logSeen ||= pattern.test(line));
|
|
205
|
+
this.#listeners.add(onLine);
|
|
148
206
|
}
|
|
149
207
|
try {
|
|
150
208
|
while (Date.now() < deadline) {
|
|
151
209
|
if (this.#exit) {
|
|
152
210
|
const why = this.#exit.error ? this.#exit.error.message : `code ${this.#exit.code}, signal ${this.#exit.signal}`;
|
|
153
|
-
throw new Error(`slicetest:
|
|
211
|
+
throw new Error(`slicetest: ${this.label} exited before becoming ready (${why})\n${this.logs()}`);
|
|
154
212
|
}
|
|
155
213
|
if ("log" in ready) {
|
|
156
214
|
if (logSeen)
|
|
@@ -168,10 +226,11 @@ export class App {
|
|
|
168
226
|
}
|
|
169
227
|
await new Promise((r) => setTimeout(r, 50));
|
|
170
228
|
}
|
|
171
|
-
throw new Error(`slicetest:
|
|
229
|
+
throw new Error(`slicetest: ${this.label} did not become ready within ${timeout}ms\n${this.logs()}`);
|
|
172
230
|
}
|
|
173
231
|
finally {
|
|
174
|
-
|
|
232
|
+
if (onLine)
|
|
233
|
+
this.#listeners.delete(onLine);
|
|
175
234
|
}
|
|
176
235
|
}
|
|
177
236
|
}
|
|
@@ -201,6 +260,9 @@ export function interpolate(template, vars, where = "app.env") {
|
|
|
201
260
|
return vars[key];
|
|
202
261
|
});
|
|
203
262
|
}
|
|
263
|
+
function escapeRegExp(s) {
|
|
264
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
265
|
+
}
|
|
204
266
|
function mapValues(obj, fn) {
|
|
205
267
|
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, fn(v)]));
|
|
206
268
|
}
|
package/dist/cli.js
CHANGED
|
@@ -11,21 +11,26 @@ import { parseArgs } from "node:util";
|
|
|
11
11
|
import { parse } from "yaml";
|
|
12
12
|
const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetest.config.json"];
|
|
13
13
|
const HELP = `Usage: slicetest [filters...] [options]
|
|
14
|
+
slicetest init [--force]
|
|
14
15
|
|
|
15
16
|
Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
|
|
17
|
+
\`slicetest init\` looks at the project and writes a starting config and scenario.
|
|
16
18
|
|
|
17
19
|
Options:
|
|
18
20
|
-c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
|
|
19
21
|
-w, --watch Re-run on changes
|
|
20
22
|
-t, --name <pattern> Only run scenarios whose name matches
|
|
23
|
+
--force init: overwrite existing files
|
|
21
24
|
-h, --help Show this help
|
|
22
25
|
|
|
23
26
|
Config (paths are relative to the config file):
|
|
24
|
-
app:
|
|
25
|
-
db:
|
|
26
|
-
stubs:
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
|
|
28
|
+
db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, seed, url, image, schemas, keep, reuse }
|
|
29
|
+
stubs: [name | { name, openapi }]
|
|
30
|
+
services: { name: { command, env, cwd, ready } }
|
|
31
|
+
openapi: file | { spec, minCoverage }
|
|
32
|
+
http: { headers, query }
|
|
33
|
+
include: [globs] default ["**/*.scenario.{yaml,yml}"]
|
|
29
34
|
`;
|
|
30
35
|
export async function main(argv = process.argv.slice(2)) {
|
|
31
36
|
const { values, positionals } = parseArgs({
|
|
@@ -36,17 +41,24 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
36
41
|
watch: { type: "boolean", short: "w" },
|
|
37
42
|
name: { type: "string", short: "t" },
|
|
38
43
|
help: { type: "boolean", short: "h" },
|
|
44
|
+
force: { type: "boolean" },
|
|
39
45
|
},
|
|
40
46
|
});
|
|
41
47
|
if (values.help) {
|
|
42
48
|
process.stdout.write(HELP);
|
|
43
49
|
return;
|
|
44
50
|
}
|
|
51
|
+
if (positionals[0] === "init") {
|
|
52
|
+
const { init } = await import("./init.js");
|
|
53
|
+
const { files, notes } = await init(process.cwd(), { force: values.force });
|
|
54
|
+
process.stdout.write(`Created ${files.join(" and ")}.\n\nWhat was detected (check these in the config):\n${notes.map((n) => ` - ${n}`).join("\n")}\n\nNext: npx slicetest\n`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
45
57
|
const configPath = values.config
|
|
46
58
|
? path.resolve(values.config)
|
|
47
59
|
: CONFIG_NAMES.map((n) => path.resolve(n)).find((p) => existsSync(p));
|
|
48
60
|
if (!configPath || !existsSync(configPath)) {
|
|
49
|
-
process.stderr.write(`slicetest: no config found.
|
|
61
|
+
process.stderr.write(`slicetest: no config found. Run "npx slicetest init" to create ${CONFIG_NAMES[0]} (see --help).\n`);
|
|
50
62
|
process.exitCode = 1;
|
|
51
63
|
return;
|
|
52
64
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -2,11 +2,53 @@ import type { RequestOptions } from "./http.js";
|
|
|
2
2
|
export interface SlicetestOptions {
|
|
3
3
|
app: AppOptions;
|
|
4
4
|
db?: DbOptions;
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Outbound HTTP services to stub. Each gets its own server, referenced as `{{stub.<name>}}` in `app.env`.
|
|
7
|
+
* With `{ name, openapi }`, the app's calls to it and the stub's replies are checked against that service's spec.
|
|
8
|
+
* With `autoReply: true` as well, calls no route matches are answered from the spec (its examples, or values
|
|
9
|
+
* built from its schemas) instead of failing, so you only register the routes a scenario cares about.
|
|
10
|
+
*/
|
|
11
|
+
stubs?: (string | {
|
|
12
|
+
name: string;
|
|
13
|
+
openapi?: string;
|
|
14
|
+
autoReply?: boolean;
|
|
15
|
+
})[];
|
|
16
|
+
/**
|
|
17
|
+
* The app's own OpenAPI 3 spec (YAML or JSON, relative to the root). Every
|
|
18
|
+
* response the app gives during a scenario must be documented and match its
|
|
19
|
+
* schema, or the scenario fails. At the end of the run, slicetest prints which
|
|
20
|
+
* documented responses the scenarios produced; with `minCoverage` (percent),
|
|
21
|
+
* a lower coverage fails the run.
|
|
22
|
+
*/
|
|
23
|
+
openapi?: string | {
|
|
24
|
+
spec: string;
|
|
25
|
+
minCoverage?: number;
|
|
26
|
+
};
|
|
7
27
|
/** Defaults for every request made with `http`, e.g. `{ headers: { accept: "application/json" } }`. */
|
|
8
28
|
http?: RequestOptions;
|
|
29
|
+
/**
|
|
30
|
+
* Other processes the app needs: a queue worker, another microservice, a
|
|
31
|
+
* mock written in another language. They start before the app, in order, and
|
|
32
|
+
* are watched like the app: a crash fails the scenario and they're restarted.
|
|
33
|
+
* Their URL is `{{service.<name>}}` and their port `{{service.<name>.port}}`,
|
|
34
|
+
* usable in `app.env` and in the env of services declared after them.
|
|
35
|
+
*/
|
|
36
|
+
services?: Record<string, ServiceOptions>;
|
|
37
|
+
}
|
|
38
|
+
export interface ServiceOptions extends Omit<AppOptions, "ready"> {
|
|
39
|
+
/** Default: no wait (for workers that don't listen). `{ path }` polls the service's own port. */
|
|
40
|
+
ready?: AppOptions["ready"];
|
|
9
41
|
}
|
|
42
|
+
type ResolvedReady = {
|
|
43
|
+
path: string;
|
|
44
|
+
} | {
|
|
45
|
+
log: string;
|
|
46
|
+
flags: string;
|
|
47
|
+
};
|
|
48
|
+
/** A process to start, with `ready` made JSON-serializable. The app always has `ready`; services may not. */
|
|
49
|
+
export type ResolvedProcess = Omit<AppOptions, "ready"> & {
|
|
50
|
+
ready?: ResolvedReady;
|
|
51
|
+
};
|
|
10
52
|
export interface AppOptions {
|
|
11
53
|
/** Command that starts the app, run through the shell. */
|
|
12
54
|
command: string;
|
|
@@ -41,6 +83,13 @@ export interface DbOptions {
|
|
|
41
83
|
schemas?: string[];
|
|
42
84
|
/** Extra tables kept across resets, in addition to known migration bookkeeping tables. */
|
|
43
85
|
keep?: string[];
|
|
86
|
+
/**
|
|
87
|
+
* Keep the Postgres container running between runs and cache the migrated
|
|
88
|
+
* template by the contents of the migrations, so a run with unchanged
|
|
89
|
+
* migrations skips both container start-up and migrating.
|
|
90
|
+
* Default: on, except when `CI` is set or `url` is given.
|
|
91
|
+
*/
|
|
92
|
+
reuse?: boolean;
|
|
44
93
|
}
|
|
45
94
|
export type MigrateOptions = {
|
|
46
95
|
atlas: {
|
|
@@ -50,20 +99,30 @@ export type MigrateOptions = {
|
|
|
50
99
|
sql: string;
|
|
51
100
|
} | {
|
|
52
101
|
command: string;
|
|
102
|
+
/**
|
|
103
|
+
* Files or directories the command reads (e.g. `["prisma/migrations"]`).
|
|
104
|
+
* With `reuse`, the migrated template is cached until one of them changes;
|
|
105
|
+
* without `inputs`, the command runs on every run.
|
|
106
|
+
*/
|
|
107
|
+
inputs?: string[];
|
|
53
108
|
};
|
|
54
109
|
/** Normalized shape passed from the plugin to globalSetup and workers. Must stay JSON-serializable. */
|
|
55
110
|
export interface ResolvedOptions {
|
|
56
111
|
root: string;
|
|
57
112
|
app: Omit<AppOptions, "ready"> & {
|
|
58
|
-
ready:
|
|
59
|
-
path: string;
|
|
60
|
-
} | {
|
|
61
|
-
log: string;
|
|
62
|
-
flags: string;
|
|
63
|
-
};
|
|
113
|
+
ready: ResolvedReady;
|
|
64
114
|
};
|
|
65
|
-
|
|
115
|
+
services: Record<string, ResolvedProcess>;
|
|
116
|
+
db: Required<Pick<DbOptions, "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "image" | "schemas" | "keep" | "reuse">;
|
|
66
117
|
stubs: string[];
|
|
118
|
+
/** Spec files, resolved against the root: the app's, and per stub name. */
|
|
119
|
+
openapi: {
|
|
120
|
+
app?: string;
|
|
121
|
+
minCoverage?: number;
|
|
122
|
+
stubs: Record<string, string>;
|
|
123
|
+
autoReply: string[];
|
|
124
|
+
};
|
|
67
125
|
http?: RequestOptions;
|
|
68
126
|
}
|
|
69
127
|
export declare function resolveOptions(opts: SlicetestOptions, root: string): ResolvedOptions;
|
|
128
|
+
export {};
|