slicetest 0.1.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/LICENSE +21 -0
- package/README.md +296 -0
- package/dist/app.d.ts +31 -0
- package/dist/app.js +217 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +70 -0
- package/dist/config.d.ts +69 -0
- package/dist/config.js +54 -0
- package/dist/container-runtime.d.ts +8 -0
- package/dist/container-runtime.js +31 -0
- package/dist/db.d.ts +39 -0
- package/dist/db.js +166 -0
- package/dist/global-setup.d.ts +9 -0
- package/dist/global-setup.js +105 -0
- package/dist/http.d.ts +51 -0
- package/dist/http.js +158 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1 -0
- package/dist/matchers.d.ts +17 -0
- package/dist/matchers.js +69 -0
- package/dist/provided.d.ts +11 -0
- package/dist/provided.js +1 -0
- package/dist/runtime.d.ts +34 -0
- package/dist/runtime.js +145 -0
- package/dist/scenario.d.ts +15 -0
- package/dist/scenario.js +59 -0
- package/dist/setup-file.d.ts +2 -0
- package/dist/setup-file.js +14 -0
- package/dist/stub.d.ts +75 -0
- package/dist/stub.js +228 -0
- package/dist/vitest.d.ts +6 -0
- package/dist/vitest.js +46 -0
- package/dist/yaml-runtime.d.ts +18 -0
- package/dist/yaml-runtime.js +255 -0
- package/dist/yaml.d.ts +99 -0
- package/dist/yaml.js +151 -0
- package/package.json +76 -0
- package/schema/scenario.schema.json +405 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pon-bok
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# slicetest
|
|
2
|
+
|
|
3
|
+
[](https://github.com/revo1290/slicetest/actions/workflows/ci.yml) [](https://www.npmjs.com/package/slicetest) [](LICENSE)
|
|
4
|
+
|
|
5
|
+
Tests that sit between unit tests and end-to-end tests, for apps written in any language or framework.
|
|
6
|
+
|
|
7
|
+
slicetest starts your app as a real process, points it at a real Postgres and at stub servers for the services it calls, and lets you check all three sides in one scenario:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { expect } from "vitest";
|
|
11
|
+
import { scenario } from "slicetest";
|
|
12
|
+
|
|
13
|
+
scenario("creating a poll stores it and notifies Slack", async ({ http, db, stub }) => {
|
|
14
|
+
stub("slack").on("POST", "/hook").reply(200, "ok");
|
|
15
|
+
|
|
16
|
+
const res = await http.post("/polls", { title: "Dogs or cats?", a: "Dogs", b: "Cats" });
|
|
17
|
+
|
|
18
|
+
expect(res).toHaveStatus(201);
|
|
19
|
+
await expect(db).toHaveRow("polls", { title: "Dogs or cats?" });
|
|
20
|
+
expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "New poll: Dogs or cats?" } });
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
No browser, no mocked database, no hooks inside your app. The app only has to read its port, database URL and outbound base URLs from environment variables.
|
|
25
|
+
|
|
26
|
+
## Why
|
|
27
|
+
|
|
28
|
+
- **Unit tests** mock the database and the network, so broken SQL, migrations and request payloads slip through.
|
|
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.
|
|
31
|
+
|
|
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
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npm i -D slicetest vitest
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
You also need Docker or Podman. slicetest finds a running Podman machine on its own (on Windows too). Alternatively, pass `db.url` or set `SLICETEST_DATABASE_URL` to use an existing Postgres server, for example a CI service container.
|
|
41
|
+
|
|
42
|
+
Works on macOS, Linux and Windows. On Windows the app's process tree is stopped with `taskkill /T`, and `app.command` / `db.migrate.command` run through `cmd.exe`.
|
|
43
|
+
|
|
44
|
+
## Configure
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// vitest.config.ts
|
|
48
|
+
import { defineConfig } from "vitest/config";
|
|
49
|
+
import { slicetest } from "slicetest/vitest";
|
|
50
|
+
|
|
51
|
+
export default defineConfig({
|
|
52
|
+
plugins: [
|
|
53
|
+
slicetest({
|
|
54
|
+
app: {
|
|
55
|
+
command: "python server.py", // any language
|
|
56
|
+
env: {
|
|
57
|
+
PORT: "{{app.port}}",
|
|
58
|
+
DATABASE_URL: "{{db.url}}",
|
|
59
|
+
SLACK_WEBHOOK_URL: "{{stub.slack}}/hook",
|
|
60
|
+
},
|
|
61
|
+
ready: { path: "/health" }, // or { log: "listening" }
|
|
62
|
+
},
|
|
63
|
+
db: {
|
|
64
|
+
migrate: { atlas: { dir: "file://migrations" } }, // or { sql: "schema.sql" } / { command: "npm run migrate" }
|
|
65
|
+
seed: "seed.sql", // re-applied after every reset
|
|
66
|
+
},
|
|
67
|
+
stubs: ["slack"],
|
|
68
|
+
}),
|
|
69
|
+
],
|
|
70
|
+
test: { include: ["scenarios/**/*.test.ts"] },
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### How a run works
|
|
75
|
+
|
|
76
|
+
1. **Once per run.** slicetest starts `postgres:17-alpine` and migrates a template database.
|
|
77
|
+
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 or called a stub route you didn't register.
|
|
81
|
+
|
|
82
|
+
Database names are unique per run, so several projects or CI jobs can share one Postgres server via `db.url`.
|
|
83
|
+
|
|
84
|
+
### When a scenario fails
|
|
85
|
+
|
|
86
|
+
slicetest prints what happened during that scenario, next to Vitest's own error:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
--- slicetest ---
|
|
90
|
+
stub calls with no matching route:
|
|
91
|
+
mail: POST /send
|
|
92
|
+
registered on mail: POST /other
|
|
93
|
+
|
|
94
|
+
requests to the app:
|
|
95
|
+
POST /signup → 500 (14ms) {"error":"internal"}
|
|
96
|
+
|
|
97
|
+
app output during this scenario:
|
|
98
|
+
TypeError: Cannot read properties of undefined (reading 'email')
|
|
99
|
+
-----------------
|
|
100
|
+
```
|
|
101
|
+
|
|
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`.
|
|
103
|
+
|
|
104
|
+
## API
|
|
105
|
+
|
|
106
|
+
Every scenario receives `{ http, db, stub, app }`.
|
|
107
|
+
|
|
108
|
+
### `http` — talk to the app
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const res = await http.post("/polls", { title: "x" }); // objects are sent as JSON
|
|
112
|
+
res.status; res.headers; res.text; res.json; res.durationMs;
|
|
113
|
+
|
|
114
|
+
await http.get("/polls", { query: { page: 2 }, headers: { accept: "text/html" } });
|
|
115
|
+
await http.post("/login", http.form({ user: "a", pass: "b" })); // urlencoded; FormData, Blob and bytes also work
|
|
116
|
+
await http.get("/old-path", { follow: true }); // redirects are NOT followed by default
|
|
117
|
+
|
|
118
|
+
const admin = http.with({ headers: { authorization: `Bearer ${token}` } }); // shares cookies with http
|
|
119
|
+
http.cookies.get("session"); // cookies persist within a scenario
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
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.
|
|
123
|
+
|
|
124
|
+
### `db` — arrange and inspect the real database
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
await db.insert("users", [{ name: "a" }, { name: "b" }]); // returns the stored rows
|
|
128
|
+
const user = await db.one("users", { email: "a@example.com" }); // throws unless exactly one row
|
|
129
|
+
await db.rows("votes", { poll_id: [1, 2], deleted_at: null }, { orderBy: "-id", limit: 10 });
|
|
130
|
+
await db.count("votes", { choice: "a" });
|
|
131
|
+
await db.sql`SELECT * FROM users WHERE id = ${user.id}`; // values become bind parameters
|
|
132
|
+
await db.query("UPDATE users SET name = $1", ["b"]);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
In `where`, `null` means `IS NULL` and an array means `IN (...)`. `bigint` columns (bigserial ids, `count(*)`) come back as numbers when they fit safely.
|
|
136
|
+
|
|
137
|
+
### `stub(name)` — fake the services the app calls
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
stub("github").on("GET", "/repos/:owner/:repo").reply((call) => ({ body: { name: call.params.repo } }));
|
|
141
|
+
|
|
142
|
+
stub("stripe")
|
|
143
|
+
.on("POST", "/v1/charges", {
|
|
144
|
+
query: { expand: "customer" },
|
|
145
|
+
headers: { authorization: /^Bearer / },
|
|
146
|
+
json: { amount: expect.any(Number) }, // subset match; asymmetric matchers and RegExps work anywhere
|
|
147
|
+
})
|
|
148
|
+
.reply(200, { id: "ch_1" });
|
|
149
|
+
|
|
150
|
+
stub("slack").on("POST", "/hook").once().reply(500); // first call fails, then falls through…
|
|
151
|
+
stub("slack").on("POST", "/hook").reply(200); // …to this route: test your retry logic
|
|
152
|
+
stub("pay").on("GET", "/status").replySequence([{ status: 503 }, { status: 200 }]);
|
|
153
|
+
stub("pay").on("POST", "/charge").delay(5_000).reply(200); // exercise the app's timeouts
|
|
154
|
+
stub("pay").on("POST", "/charge").networkError(); // drop the connection
|
|
155
|
+
|
|
156
|
+
stub("slack").calls("POST", "/hook"); // recorded calls: method, path, params, query, headers, body, json
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Later routes win. `path` may also be a RegExp, and `method` may be `*`. Unanswered calls get a `501` and fail the scenario.
|
|
160
|
+
|
|
161
|
+
### Matchers
|
|
162
|
+
|
|
163
|
+
Registered automatically:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
expect(res).toHaveStatus(201); // failure shows the response body
|
|
167
|
+
expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "hi" } });
|
|
168
|
+
expect(stub("slack")).toHaveReceivedTimes(1, "POST", "/hook");
|
|
169
|
+
expect(stub("mail")).not.toHaveReceived("POST", "/send");
|
|
170
|
+
await expect(db).toHaveRow("polls", { title: "x" }); // at least one row
|
|
171
|
+
await expect(db).toHaveRow("votes", { poll_id: 1 }, 3); // exactly three
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Failure messages list the calls the stub actually received, or the first rows of the table.
|
|
175
|
+
|
|
176
|
+
### Scenarios
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
scenario("name", async ({ http, db, stub, app }) => { ... }, timeoutMs?);
|
|
180
|
+
scenario.only / scenario.skip / scenario.todo
|
|
181
|
+
scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
|
|
182
|
+
"voting $choice returns $status",
|
|
183
|
+
async ({ choice, status }, { http }) => { ... },
|
|
184
|
+
);
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Scenarios in one file share an app and a database, so they always run one at a time; `.concurrent` is rejected.
|
|
188
|
+
|
|
189
|
+
### Configuration reference
|
|
190
|
+
|
|
191
|
+
| Option | Default | |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| `app.command` | (required) | Shell command. May use `{{app.port}}` and the other placeholders. |
|
|
194
|
+
| `app.env` | `{ PORT, DATABASE_URL }` | Values may use `{{app.port}}`, `{{db.url}}`, `{{stub.<name>}}`. The rest of `process.env` is inherited. |
|
|
195
|
+
| `app.cwd` | vitest root | |
|
|
196
|
+
| `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
|
|
197
|
+
| `app.readyTimeout` | `30000` | |
|
|
198
|
+
| `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command }` (gets `DATABASE_URL`). |
|
|
199
|
+
| `db.seed` | none | SQL file re-run after every reset. |
|
|
200
|
+
| `db.schemas` | `["public"]` | Schemas whose tables are reset. |
|
|
201
|
+
| `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
|
|
202
|
+
| `db.url` | `$SLICETEST_DATABASE_URL`, else a container | Use an existing Postgres server (e.g. a CI service container) instead of Testcontainers. |
|
|
203
|
+
| `db.image` | `postgres:17-alpine` | |
|
|
204
|
+
| `stubs` | `[]` | Names of stubbed services. |
|
|
205
|
+
| `http` | `{}` | Default `headers` / `query` for every request. |
|
|
206
|
+
|
|
207
|
+
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.
|
|
208
|
+
|
|
209
|
+
## YAML scenarios
|
|
210
|
+
|
|
211
|
+
Everything above is also available as data, for teams that don't write JavaScript. Files named `*.scenario.yaml` are picked up automatically, next to your `.test.ts` files, and run with the same app, database and stubs:
|
|
212
|
+
|
|
213
|
+
```yaml
|
|
214
|
+
# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json
|
|
215
|
+
scenarios:
|
|
216
|
+
- name: creating a poll stores it and notifies Slack
|
|
217
|
+
steps:
|
|
218
|
+
- stub: slack
|
|
219
|
+
on: POST /hook
|
|
220
|
+
reply: { status: 200, body: ok }
|
|
221
|
+
|
|
222
|
+
- request: POST /polls
|
|
223
|
+
json: { title: Dogs or cats?, a: Dogs, b: Cats }
|
|
224
|
+
expect:
|
|
225
|
+
status: 201
|
|
226
|
+
json: { id: { $type: number } }
|
|
227
|
+
capture: { pollId: json.id }
|
|
228
|
+
|
|
229
|
+
- db: polls
|
|
230
|
+
where: { title: Dogs or cats? }
|
|
231
|
+
expect:
|
|
232
|
+
rows: [{ id: "{{pollId}}", option_a: Dogs }]
|
|
233
|
+
|
|
234
|
+
- received: slack
|
|
235
|
+
call: POST /hook
|
|
236
|
+
times: 1
|
|
237
|
+
when:
|
|
238
|
+
json: { text: "New poll: Dogs or cats?" }
|
|
239
|
+
|
|
240
|
+
- name: voting {{choice}} returns {{status}}
|
|
241
|
+
each:
|
|
242
|
+
- { choice: a, status: 204 }
|
|
243
|
+
- { choice: x, status: 400 }
|
|
244
|
+
steps:
|
|
245
|
+
- request: POST /polls/1/votes
|
|
246
|
+
json: { choice: "{{choice}}" }
|
|
247
|
+
expect: { status: "{{status}}" }
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
| Step | Keys |
|
|
251
|
+
|---|---|
|
|
252
|
+
| `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}}`. |
|
|
253
|
+
| `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `body`, `follow`, `expect: { status, headers, json, text }`, `capture` |
|
|
254
|
+
| `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
|
|
255
|
+
| `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
|
|
256
|
+
| `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
|
|
257
|
+
| `received: <stub>` | `call: METHOD /path`, `when`, `times` (exact; default at least once) |
|
|
258
|
+
|
|
259
|
+
- `{{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
|
+
- Expected `json`, `rows` and `headers` are subsets: extra keys are fine. `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "..." }` and `{ $any: true }` match loosely.
|
|
261
|
+
- A file-level `setup:` list runs at the start of every scenario. `skip`, `only` and `timeout` work per scenario.
|
|
262
|
+
- Mistakes are reported with the file and line before anything runs (`polls.scenario.yaml:12: unknown key "stauts" in expect`). A failing step reports its file, line and step number. The JSON Schema in `schema/` gives editors completion and inline errors.
|
|
263
|
+
|
|
264
|
+
### Without any JavaScript: `npx slicetest`
|
|
265
|
+
|
|
266
|
+
Put the plugin options in `slicetest.config.yaml` and run the CLI. It needs Node, but no `package.json` scripts, TypeScript or Vitest config:
|
|
267
|
+
|
|
268
|
+
```yaml
|
|
269
|
+
# slicetest.config.yaml
|
|
270
|
+
app:
|
|
271
|
+
command: python server.py
|
|
272
|
+
env: { PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}", SLACK_WEBHOOK_URL: "{{stub.slack}}/hook" }
|
|
273
|
+
ready: { path: /health }
|
|
274
|
+
db:
|
|
275
|
+
migrate: { command: alembic upgrade head }
|
|
276
|
+
stubs: [slack]
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
```sh
|
|
280
|
+
npx slicetest # every *.scenario.yaml under the config's directory
|
|
281
|
+
npx slicetest polls -t voting # filter by file and scenario name
|
|
282
|
+
npx slicetest --watch
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Examples
|
|
286
|
+
|
|
287
|
+
`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**:
|
|
288
|
+
|
|
289
|
+
```sh
|
|
290
|
+
npm test # unit tests + both example apps
|
|
291
|
+
npm run test:dist # the built package, and the CLI with examples/slicetest.config.yaml
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Status
|
|
295
|
+
|
|
296
|
+
Early prototype. Postgres only. CI runs on Linux and Windows. Planned: container reuse across runs, MySQL.
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ResolvedOptions } from "./config.js";
|
|
2
|
+
type Exit = {
|
|
3
|
+
code: number | null;
|
|
4
|
+
signal: NodeJS.Signals | null;
|
|
5
|
+
error?: Error;
|
|
6
|
+
};
|
|
7
|
+
/** The app under test, running as a real child process. */
|
|
8
|
+
export declare class App {
|
|
9
|
+
#private;
|
|
10
|
+
readonly url: string;
|
|
11
|
+
private constructor();
|
|
12
|
+
static start(opts: ResolvedOptions["app"], root: string, vars: Record<string, string>): Promise<App>;
|
|
13
|
+
get exited(): Exit | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* The exit event is delivered asynchronously, so a crash caused by the last
|
|
16
|
+
* request may not be visible yet. Give it a moment before trusting `exited`.
|
|
17
|
+
*/
|
|
18
|
+
settle(ms?: number): Promise<Exit | undefined>;
|
|
19
|
+
/** The app's recent output (last 200 lines), or only what it printed after `since` (a value from `mark()`). */
|
|
20
|
+
logs(since?: number): string;
|
|
21
|
+
mark(): number;
|
|
22
|
+
/**
|
|
23
|
+
* POSIX: SIGTERM the whole process group, then SIGKILL whatever is left (including orphaned grandchildren).
|
|
24
|
+
* Windows has no signals to ask politely with, so the tree is terminated at once.
|
|
25
|
+
*/
|
|
26
|
+
stop(): Promise<void>;
|
|
27
|
+
/** Synchronous last resort for process exit. */
|
|
28
|
+
killNow(): void;
|
|
29
|
+
}
|
|
30
|
+
export declare function interpolate(template: string, vars: Record<string, string>, where?: string): string;
|
|
31
|
+
export {};
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
5
|
+
const LOG_LINES = 200;
|
|
6
|
+
/** How long to wait for stdout/stderr to drain after the process exits. */
|
|
7
|
+
const DRAIN_MS = 100;
|
|
8
|
+
const KILL_GRACE_MS = 3000;
|
|
9
|
+
const WINDOWS = process.platform === "win32";
|
|
10
|
+
/** The app under test, running as a real child process. */
|
|
11
|
+
export class App {
|
|
12
|
+
url;
|
|
13
|
+
#child;
|
|
14
|
+
#log = [];
|
|
15
|
+
/** Lines ever received, so callers can ask for output since a point in time. */
|
|
16
|
+
#lineCount = 0;
|
|
17
|
+
#exit;
|
|
18
|
+
#exited;
|
|
19
|
+
#onLine;
|
|
20
|
+
constructor(child, url) {
|
|
21
|
+
this.url = url;
|
|
22
|
+
this.#child = child;
|
|
23
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
24
|
+
// Keep partial lines (and split multi-byte characters) until the rest arrives.
|
|
25
|
+
const decoder = new StringDecoder("utf8");
|
|
26
|
+
let partial = "";
|
|
27
|
+
const push = (line) => {
|
|
28
|
+
if (!line)
|
|
29
|
+
return;
|
|
30
|
+
this.#log.push(line);
|
|
31
|
+
this.#lineCount++;
|
|
32
|
+
if (this.#log.length > LOG_LINES)
|
|
33
|
+
this.#log.shift();
|
|
34
|
+
this.#onLine?.(line);
|
|
35
|
+
};
|
|
36
|
+
stream.on("data", (chunk) => {
|
|
37
|
+
const lines = (partial + decoder.write(chunk)).split(/\r?\n/);
|
|
38
|
+
partial = lines.pop();
|
|
39
|
+
lines.forEach(push);
|
|
40
|
+
});
|
|
41
|
+
stream.on("end", () => push(partial + decoder.end()));
|
|
42
|
+
}
|
|
43
|
+
this.#exited = new Promise((resolve) => {
|
|
44
|
+
const done = (exit) => {
|
|
45
|
+
this.#exit ??= exit;
|
|
46
|
+
resolve(this.#exit);
|
|
47
|
+
};
|
|
48
|
+
child.on("error", (error) => done({ code: null, signal: null, error }));
|
|
49
|
+
// 'exit' can fire before the output is read; prefer 'close', but don't wait
|
|
50
|
+
// forever when a grandchild still holds the pipes open.
|
|
51
|
+
child.on("exit", (code, signal) => setTimeout(() => done({ code, signal }), DRAIN_MS).unref());
|
|
52
|
+
child.on("close", (code, signal) => done({ code, signal }));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
static async start(opts, root, vars) {
|
|
56
|
+
const port = await freePort();
|
|
57
|
+
vars = { ...vars, "app.port": String(port) };
|
|
58
|
+
const env = opts.env ?? { PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}" };
|
|
59
|
+
const child = spawn(interpolate(opts.command, vars, "app.command"), {
|
|
60
|
+
shell: true,
|
|
61
|
+
cwd: path.resolve(root, opts.cwd ?? "."),
|
|
62
|
+
env: { ...process.env, ...mapValues(env, (v) => interpolate(v, vars, "app.env")) },
|
|
63
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
64
|
+
// POSIX: own process group, so stop() also kills whatever the shell spawned.
|
|
65
|
+
// Windows: detaching would open a console window; taskkill /T walks the tree instead.
|
|
66
|
+
detached: !WINDOWS,
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
});
|
|
69
|
+
const app = new App(child, `http://127.0.0.1:${port}`);
|
|
70
|
+
running.add(app);
|
|
71
|
+
try {
|
|
72
|
+
await app.#waitReady(opts);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
await app.stop();
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
return app;
|
|
79
|
+
}
|
|
80
|
+
get exited() {
|
|
81
|
+
return this.#exit;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The exit event is delivered asynchronously, so a crash caused by the last
|
|
85
|
+
* request may not be visible yet. Give it a moment before trusting `exited`.
|
|
86
|
+
*/
|
|
87
|
+
async settle(ms = 20) {
|
|
88
|
+
if (this.#exit)
|
|
89
|
+
return this.#exit;
|
|
90
|
+
let timer;
|
|
91
|
+
await Promise.race([this.#exited, new Promise((r) => (timer = setTimeout(r, ms)))]);
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
return this.#exit;
|
|
94
|
+
}
|
|
95
|
+
/** The app's recent output (last 200 lines), or only what it printed after `since` (a value from `mark()`). */
|
|
96
|
+
logs(since) {
|
|
97
|
+
if (since === undefined)
|
|
98
|
+
return this.#log.join("\n");
|
|
99
|
+
const n = Math.min(this.#lineCount - since, this.#log.length);
|
|
100
|
+
return n > 0 ? this.#log.slice(-n).join("\n") : "";
|
|
101
|
+
}
|
|
102
|
+
mark() {
|
|
103
|
+
return this.#lineCount;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* POSIX: SIGTERM the whole process group, then SIGKILL whatever is left (including orphaned grandchildren).
|
|
107
|
+
* Windows has no signals to ask politely with, so the tree is terminated at once.
|
|
108
|
+
*/
|
|
109
|
+
async stop() {
|
|
110
|
+
running.delete(this);
|
|
111
|
+
const pid = this.#child.pid;
|
|
112
|
+
if (pid === undefined)
|
|
113
|
+
return;
|
|
114
|
+
if (WINDOWS) {
|
|
115
|
+
if (!this.#exit)
|
|
116
|
+
killTree(pid);
|
|
117
|
+
await this.settle(KILL_GRACE_MS);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!this.#exit) {
|
|
121
|
+
killGroup(pid, "SIGTERM");
|
|
122
|
+
let timer;
|
|
123
|
+
await Promise.race([this.#exited, new Promise((r) => (timer = setTimeout(r, KILL_GRACE_MS)))]);
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
killGroup(pid, "SIGKILL");
|
|
127
|
+
}
|
|
128
|
+
/** Synchronous last resort for process exit. */
|
|
129
|
+
killNow() {
|
|
130
|
+
const pid = this.#child.pid;
|
|
131
|
+
if (pid === undefined)
|
|
132
|
+
return;
|
|
133
|
+
if (WINDOWS)
|
|
134
|
+
killTree(pid);
|
|
135
|
+
else
|
|
136
|
+
killGroup(pid, "SIGKILL");
|
|
137
|
+
}
|
|
138
|
+
async #waitReady(opts) {
|
|
139
|
+
const timeout = opts.readyTimeout ?? 30_000;
|
|
140
|
+
const deadline = Date.now() + timeout;
|
|
141
|
+
const ready = opts.ready;
|
|
142
|
+
let logSeen = false;
|
|
143
|
+
if ("log" in ready) {
|
|
144
|
+
// Drop g/y so test() doesn't keep state between lines.
|
|
145
|
+
const pattern = new RegExp(ready.log, ready.flags.replace(/[gy]/g, ""));
|
|
146
|
+
logSeen = this.#log.some((l) => pattern.test(l));
|
|
147
|
+
this.#onLine = (line) => (logSeen ||= pattern.test(line));
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
while (Date.now() < deadline) {
|
|
151
|
+
if (this.#exit) {
|
|
152
|
+
const why = this.#exit.error ? this.#exit.error.message : `code ${this.#exit.code}, signal ${this.#exit.signal}`;
|
|
153
|
+
throw new Error(`slicetest: app exited before becoming ready (${why})\n${this.logs()}`);
|
|
154
|
+
}
|
|
155
|
+
if ("log" in ready) {
|
|
156
|
+
if (logSeen)
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(this.url + ready.path, { signal: AbortSignal.timeout(1000) });
|
|
162
|
+
await res.body?.cancel();
|
|
163
|
+
// Our child may not own the port yet if something else grabbed it; only trust a live child.
|
|
164
|
+
if (res.status < 500 && !this.#exit)
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
catch { }
|
|
168
|
+
}
|
|
169
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`slicetest: app did not become ready within ${timeout}ms\n${this.logs()}`);
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
this.#onLine = undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Apps still running, killed synchronously if the worker exits without tearing down. */
|
|
179
|
+
const running = new Set();
|
|
180
|
+
process.once("exit", () => {
|
|
181
|
+
for (const app of running)
|
|
182
|
+
app.killNow();
|
|
183
|
+
});
|
|
184
|
+
/** Windows: terminate the shell and everything it started. Synchronous so it also works on process exit. */
|
|
185
|
+
function killTree(pid) {
|
|
186
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
187
|
+
}
|
|
188
|
+
function killGroup(pid, signal) {
|
|
189
|
+
try {
|
|
190
|
+
process.kill(-pid, signal);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Group already gone.
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export function interpolate(template, vars, where = "app.env") {
|
|
197
|
+
return template.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_, key) => {
|
|
198
|
+
if (!(key in vars)) {
|
|
199
|
+
throw new Error(`slicetest: unknown placeholder {{${key}}} in ${where}. Available: ${Object.keys(vars).map((k) => `{{${k}}}`).join(", ")}`);
|
|
200
|
+
}
|
|
201
|
+
return vars[key];
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function mapValues(obj, fn) {
|
|
205
|
+
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, fn(v)]));
|
|
206
|
+
}
|
|
207
|
+
function freePort() {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const srv = createServer();
|
|
210
|
+
srv.unref();
|
|
211
|
+
srv.on("error", reject);
|
|
212
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
213
|
+
const { port } = srv.address();
|
|
214
|
+
srv.close(() => resolve(port));
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `npx slicetest`: run YAML scenarios without writing any JavaScript.
|
|
4
|
+
* Reads slicetest.config.yaml (the same options as the Vitest plugin) and
|
|
5
|
+
* drives Vitest programmatically.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
import { parse } from "yaml";
|
|
12
|
+
const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetest.config.json"];
|
|
13
|
+
const HELP = `Usage: slicetest [filters...] [options]
|
|
14
|
+
|
|
15
|
+
Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
-c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
|
|
19
|
+
-w, --watch Re-run on changes
|
|
20
|
+
-t, --name <pattern> Only run scenarios whose name matches
|
|
21
|
+
-h, --help Show this help
|
|
22
|
+
|
|
23
|
+
Config (paths are relative to the config file):
|
|
24
|
+
app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
|
|
25
|
+
db: { migrate: { atlas: { dir } } | { sql } | { command }, seed, url, image, schemas, keep }
|
|
26
|
+
stubs: [names]
|
|
27
|
+
http: { headers, query }
|
|
28
|
+
include: [globs] default ["**/*.scenario.{yaml,yml}"]
|
|
29
|
+
`;
|
|
30
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
31
|
+
const { values, positionals } = parseArgs({
|
|
32
|
+
args: argv,
|
|
33
|
+
allowPositionals: true,
|
|
34
|
+
options: {
|
|
35
|
+
config: { type: "string", short: "c" },
|
|
36
|
+
watch: { type: "boolean", short: "w" },
|
|
37
|
+
name: { type: "string", short: "t" },
|
|
38
|
+
help: { type: "boolean", short: "h" },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
if (values.help) {
|
|
42
|
+
process.stdout.write(HELP);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const configPath = values.config
|
|
46
|
+
? path.resolve(values.config)
|
|
47
|
+
: CONFIG_NAMES.map((n) => path.resolve(n)).find((p) => existsSync(p));
|
|
48
|
+
if (!configPath || !existsSync(configPath)) {
|
|
49
|
+
process.stderr.write(`slicetest: no config found. Create ${CONFIG_NAMES[0]} (see --help).\n`);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const { include, ...options } = (parse(await readFile(configPath, "utf8")) ?? {});
|
|
54
|
+
const { startVitest } = await import("vitest/node");
|
|
55
|
+
const { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
|
|
56
|
+
const vitest = await startVitest(positionals, {
|
|
57
|
+
config: false,
|
|
58
|
+
root: path.dirname(configPath),
|
|
59
|
+
include: include ?? [YAML_SCENARIOS],
|
|
60
|
+
watch: !!values.watch,
|
|
61
|
+
run: !values.watch,
|
|
62
|
+
testNamePattern: values.name,
|
|
63
|
+
}, { plugins: [slicetest(options)] });
|
|
64
|
+
if (!values.watch)
|
|
65
|
+
await vitest?.close();
|
|
66
|
+
}
|
|
67
|
+
main().catch((e) => {
|
|
68
|
+
process.stderr.write(`${e instanceof Error ? e.message : e}\n`);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
});
|