slicetest 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Tests that sit between unit tests and end-to-end tests, for apps written in any language or framework.
6
6
 
7
- slicetest starts your app as a real process, points it at a real Postgres (or MySQL) and at stub servers for the services it calls, and lets you check all three sides in one scenario:
7
+ slicetest starts your app as a real process, points it at a real Postgres (or MySQL, or SQLite) and at stub servers for the services it calls, and lets you check all three sides in one scenario:
8
8
 
9
9
  ```ts
10
10
  import { expect } from "vitest";
@@ -38,7 +38,7 @@ npx slicetest init # detects your stack, writes slicetest.config.yaml and a fi
38
38
  npx slicetest # starts Postgres, migrates, starts your app, runs scenarios/*.scenario.yaml
39
39
  ```
40
40
 
41
- `init` recognises Node (`npm start`), Django, FastAPI, Flask, Rails, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Drizzle, Knex and plain SQL migrations; and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. It lists every guess as a comment in the config so you know what to check.
41
+ `init` recognises Node (`npm start`), Django, FastAPI, Flask, Rails, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Drizzle, Knex and plain SQL migrations; and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. A mail catcher there (Mailpit, MailHog, MailDev, smtp4dev, …) or a mail library in the dependencies turns on [`mail`](#mail-catch-what-the-app-sends). SQLite is picked up from Prisma's provider, Rails' `database.yml`, Django's settings or a SQLite driver, with `DATABASE_URL` in the form the framework reads (`file:…`, `sqlite3:…`). And third-party API URLs in `.env.example` (`STRIPE_API_BASE=https://api.stripe.com`) become stubs [recorded from that service](#recording-a-real-service), with the variable pointed at the stub, while local addresses, databases and your own URLs are left alone. It lists every guess as a comment in the config so you know what to check.
42
42
 
43
43
  ## What you get that's hard to find elsewhere
44
44
 
@@ -48,7 +48,11 @@ npx slicetest # starts Postgres, migrates, starts your app, runs scenario
48
48
  - **Whole-scenario snapshots.** `expect(await trace()).toMatchSnapshot()` pins the responses, the outbound calls and the database changes in one reviewable file, with dates and UUIDs masked.
49
49
  - **Record the real service once, replay forever.** Point a stub at the real API with `SLICETEST_RECORD=1`, commit the YAML it writes, and later runs are offline and deterministic.
50
50
  - **OpenAPI coverage** of your own API, per operation and status, across all scenarios, and `slicetest gen --uncovered` to scaffold scenarios for what's missing.
51
- - **Postgres or MySQL**, with the same scenarios and the same row types on both, plus Redis, MinIO or any other `containers` reset between scenarios.
51
+ - **Record instead of write.** `npx slicetest record` puts a proxy in front of the app: click through a flow, press Enter, and get a replayable YAML scenario with the stubs' answers, the responses, captured ids and the database changes.
52
+ - **Readable in CI.** On GitHub Actions, failing YAML steps are annotated in the pull request on the line that failed, and the job summary shows the OpenAPI coverage table.
53
+ - **Races on purpose.** `http.concurrently(10, ...)` and `toHaveStatuses({ 201: 1, 409: 9 })` turn "what if two people click at once" into a test against the real database.
54
+ - **Mail as a fourth boundary.** `mail: true` catches the app's SMTP traffic in-process, decoded, with the links pulled out, so a sign-up test can follow the confirmation link.
55
+ - **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.
52
56
  - **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.
53
57
 
54
58
  ## Install
@@ -57,7 +61,7 @@ npx slicetest # starts Postgres, migrates, starts your app, runs scenario
57
61
  npm i -D slicetest vitest
58
62
  ```
59
63
 
60
- 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.
64
+ You also need Docker or Podman (`npx slicetest doctor` checks). 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.
61
65
 
62
66
  ### MySQL
63
67
 
@@ -69,6 +73,19 @@ npm i -D mysql2 @testcontainers/mysql # the second is only needed without db.u
69
73
 
70
74
  Everything works the same: `mysql:8.4` in a container, a migrated template cloned per worker (tables, foreign keys, views and triggers; stored routines are not copied), a `TRUNCATE` reset that only touches tables that were written to, and `db.*` helpers whose rows look like Postgres's (`BOOLEAN` as `true`/`false`, `BIGINT` ids as numbers, `DATETIME` in UTC). Only the SQL you write yourself differs: `?` placeholders in `db.query` and YAML `sql` steps. `db.schemas` defaults to the database in the URL. `SLICETEST_DATABASE_URL` is only used by projects on the same engine as its scheme, so a CI job can provide one Postgres server while a MySQL project starts its own container.
71
75
 
76
+ ### SQLite
77
+
78
+ Set `db: { engine: "sqlite" }`. There's nothing to install and no container: slicetest uses Node's built-in `node:sqlite` (Node.js 22.5 or later), creates the database files in a temporary directory, migrates a template once and gives each worker a copy (`VACUUM INTO`). Point the app at the file:
79
+
80
+ ```ts
81
+ slicetest({
82
+ app: { command: "python app.py", env: { PORT: "{{app.port}}", DATABASE_PATH: "{{db.path}}" } },
83
+ db: { engine: "sqlite", migrate: { sql: "schema.sql" } },
84
+ });
85
+ ```
86
+
87
+ `{{db.url}}` is `sqlite:///absolute/path.db` (the form SQLAlchemy, dj-database-url and many others read) and `{{db.path}}` the plain path. The files are in WAL mode, so the app keeps its connection while slicetest resets tables between scenarios (`DELETE` plus resetting `AUTOINCREMENT` counters, foreign keys off for that moment only). `db.changes()`, `trace()` and every other helper work the same; SQLite has no boolean type, so booleans you insert are stored and read back as `1` / `0`. Migrations with `sql`, `command` or Atlas (`sqlite://` URLs). `db.url`, `db.image` and `SLICETEST_DATABASE_URL` don't apply. With the default `reuse`, the migrated template stays in the system temp directory between runs.
88
+
72
89
  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`.
73
90
 
74
91
  ## Configure
@@ -283,6 +300,7 @@ Registered automatically:
283
300
 
284
301
  ```ts
285
302
  expect(res).toHaveStatus(201); // failure shows the response body
303
+ expect(responses).toHaveStatuses({ 201: 1, 409: 9 }); // an array, e.g. from http.concurrently()
286
304
  expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "hi" } });
287
305
  expect(stub("slack")).toHaveReceivedTimes(1, "POST", "/hook");
288
306
  expect(stub("mail")).not.toHaveReceived("POST", "/send");
@@ -338,6 +356,39 @@ slicetest({
338
356
 
339
357
  `{{container.<name>}}` is `host:port`; `.host` and `.port` are there too. The container is ready when its port accepts connections, or when it prints `ready: { log }`. In a scenario, `container("cache").exec(["redis-cli", "GET", "hits"])` runs a command inside it and returns its stdout.
340
358
 
359
+ ### Races: many requests at once
360
+
361
+ Double bookings, lost updates and duplicate charges only show up when requests overlap. `http.concurrently(n, send)` prepares `n` requests and releases them together, against the real database, and `toHaveStatuses` checks how they were answered:
362
+
363
+ ```ts
364
+ scenario("ten people booking the same seat: one gets it", async ({ http, db }) => {
365
+ const responses = await http.concurrently(10, () => http.post("/bookings", { seat: 7 }));
366
+ expect(responses).toHaveStatuses({ 201: 1, 409: 9 });
367
+ await expect(db).toHaveRow("bookings", { seat: 7 }, 1);
368
+ });
369
+ ```
370
+
371
+ `send` gets the request's index, for variations. When the counts are off, the failure shows one response per status. In YAML, `concurrency: 10` on a `request` step does the same, with `expect: { statuses: { 201: 1, 409: 9 } }`.
372
+
373
+ ### Mail: catch what the app sends
374
+
375
+ `mail: true` starts an SMTP server for the app to send to (plain SMTP, no TLS, any username and password accepted). Point the app's mail settings at it, and read what arrived in the scenario, already decoded (encoded subjects, quoted-printable and base64 parts, multipart text and HTML):
376
+
377
+ ```ts
378
+ slicetest({
379
+ mail: true,
380
+ app: { command: "node server.js", env: { SMTP_HOST: "{{mail.host}}", SMTP_PORT: "{{mail.port}}" } },
381
+ });
382
+
383
+ scenario("sign-up sends a confirmation link that works", async ({ http, mail }) => {
384
+ await http.post("/signup", { json: { email: "alice@example.com" } });
385
+ const message = await mail.waitFor({ to: "alice@example.com", subject: "Confirm" });
386
+ expect((await http.get(message.links[0]!)).status).toBe(200);
387
+ });
388
+ ```
389
+
390
+ `mail.messages(filter?)`, `mail.last(filter?)` and `mail.waitFor(filter?, { within })` take `{ to, from, subject, text, html }`: addresses match exactly, other strings as substrings, and RegExps test the value. Each message has `from`, `to` (the envelope, so Cc and Bcc too), `subject`, `text`, `html`, `headers`, `links` and `raw`. The mailbox is emptied before each scenario, what was sent shows up in the failure output and in `trace()`, and `{{mail.url}}` is `smtp://host:port` for libraries that take a URL. No container is involved, so it works the same for apps in any language and on Windows.
391
+
341
392
  ### Asynchronous side effects
342
393
 
343
394
  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:
@@ -389,7 +440,7 @@ Scenarios in one file share an app and a database, so they always run one at a t
389
440
  | `app.cwd` | vitest root | |
390
441
  | `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
391
442
  | `app.readyTimeout` | `30000` | |
392
- | `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres` or `mysql` (see [MySQL](#mysql)). |
443
+ | `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
393
444
  | `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs? }` (gets `DATABASE_URL`). |
394
445
  | `db.seed` | none | SQL file re-run after every reset. |
395
446
  | `db.schemas` | `["public"]` | Schemas whose tables are reset. |
@@ -398,6 +449,7 @@ Scenarios in one file share an app and a database, so they always run one at a t
398
449
  | `db.image` | `postgres:17-alpine` / `mysql:8.4` | |
399
450
  | `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. |
400
451
  | `containers` | `{}` | Dependencies as containers: `{ name: { image, port, env?, command?, ready?: { log }, reset? } }`. See [Containers](#containers-redis-search-s3-and-other-dependencies). |
452
+ | `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). |
401
453
  | `services` | `{}` | Other processes: `{ name: { command, env?, cwd?, ready?, readyTimeout? } }`. Without `ready` a service is not waited for. |
402
454
  | `stubs` | `[]` | Names of stubbed services, or `{ name, openapi?, autoReply?, upstream?, recordings? }`: check calls against the provider's spec, answer from it, or [replay recordings](#recording-a-real-service) of the real service. |
403
455
  | `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`. Every response must match it; the run ends with a coverage report. |
@@ -452,7 +504,7 @@ scenarios:
452
504
  | Step | Keys |
453
505
  |---|---|
454
506
  | `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}}`. |
455
- | `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `body`, `follow`, `expect: { status, headers, json, text }`, `capture` |
507
+ | `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. |
456
508
  | `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
457
509
  | `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
458
510
  | `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
@@ -460,10 +512,12 @@ scenarios:
460
512
  | `log: <regex>` | `from` (a service; default the app), `within` (ms, default 5000). Waits for a matching line printed during the scenario. |
461
513
  | `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. |
462
514
  | `checkpoint: true` | Later `changes` steps only see what happens after this step. |
515
+ | `mail: { to, from, subject, text, html }` | `times` (exact; default at least one), `within` (ms, default 5000), `capture` from the last match (`subject`, `text`, `links.0`). Waits for mail the app sends. `{}` matches any message. |
463
516
  | `snapshot: true` | The scenario's [trace](#snapshot-the-whole-scenario-trace) so far must match its stored snapshot. `mask: [keys]` hides more values. |
464
517
 
465
518
  `db`, `sql`, `received` and `changes` steps take `within: <ms>` to retry until they pass, for effects the app applies asynchronously.
466
519
 
520
+ - `request:` also takes a captured URL of the app, e.g. `GET {{link}}` after capturing a link from a mail.
467
521
  - `{{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.
468
522
  - Expected `json`, `rows` and `headers` are subsets: extra keys are fine. `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "..." }` and `{ $any: true }` match loosely.
469
523
  - A file-level `setup:` list runs at the start of every scenario. `skip`, `only` and `timeout` work per scenario.
@@ -501,6 +555,48 @@ writes `scenarios/<resource>.gen.scenario.yaml` with one scenario per documented
501
555
 
502
556
  `--uncovered` reads the coverage the last run left in `node_modules/.cache/slicetest/`, which closes the loop: run, look at the ✗ in the coverage table, `gen --uncovered`, fill in the TODOs.
503
557
 
558
+ ### Record a scenario by using the app: `npx slicetest record`
559
+
560
+ The fastest way to a first scenario is to do the thing once. `record` starts the database, the stubs and the app exactly as a test run would, plus a proxy in front of the app:
561
+
562
+ ```
563
+ $ npx slicetest record
564
+ Recording. Use the app through http://127.0.0.1:52301 (it forwards to http://127.0.0.1:52288).
565
+ Everything it does is captured: responses, calls to stubs, database changes.
566
+ Press Enter (or Ctrl+C) to finish and write the scenario.
567
+
568
+ Wrote scenarios/recorded-20261001-091500.scenario.yaml: 3 request(s), 1 stub route(s), changes in polls, votes.
569
+ Replay it with: npx slicetest scenarios/recorded-20261001-091500.scenario.yaml
570
+ ```
571
+
572
+ Point a browser, curl, Postman or a mobile build at the proxy URL and go through the flow. The scenario it writes has:
573
+
574
+ - a `stub` step for every answer a stubbed service gave (from `autoReply`, or a real service with `upstream` and `SLICETEST_RECORD`), so the replay needs neither;
575
+ - a `request` step per request with the status and body to expect, where dates and UUIDs only have to be strings, and a `capture` for any value that a later request reuses (`POST /orders` → `GET /orders/{{id}}`);
576
+ - `received` steps for the calls the app made to stubs, and a closing `changes` step with the rows written per table.
577
+
578
+ Requests for scripts, styles and images are left out. The file starts with a comment of what to review: it is a starting point, and the assertions that matter to you are yours to tighten. `--out` names the file, `--port` fixes the proxy's port.
579
+
580
+ ### On GitHub Actions
581
+
582
+ Nothing to configure. When `GITHUB_ACTIONS` is set, a failing YAML step is annotated on its own line of the `.scenario.yaml` file in the pull request (Vitest already does this for TypeScript tests), and the job summary gets a table of the failed steps and the OpenAPI coverage table, with ✅ / ❌ per documented response. A coverage below `minCoverage` is annotated on the spec file.
583
+
584
+ ### Is everything in place? `npx slicetest doctor`
585
+
586
+ Checks what a run needs before it starts, instead of failing with a timeout halfway: the config, the container runtime (or the database server at `db.url` / `SLICETEST_DATABASE_URL`, with the password hidden), the migrations, seed and working directories, the `atlas` CLI, `mysql2` for MySQL, the programs the app and services start, the OpenAPI files and missing recordings. Each problem says what to do, and the exit code is 1 when something must be fixed, so it also works as the first step of a CI job.
587
+
588
+ ```
589
+ ✓ Node.js 24.13.0
590
+ ✓ config slicetest.config.yaml
591
+ ✗ no container runtime
592
+ Could not find a working container runtime strategy. Start Docker or a Podman machine (`podman machine start`), or set SLICETEST_DATABASE_URL / db.url to an existing database server
593
+ ✓ migrations migrations
594
+ ! stub github: no recordings yet (recordings/github.yaml)
595
+ run once with SLICETEST_RECORD=github to record https://api.github.com
596
+
597
+ 1 problem(s) to fix before running.
598
+ ```
599
+
504
600
  ## Examples
505
601
 
506
602
  `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/ci.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * GitHub Actions output. Vitest already annotates failing TypeScript tests;
3
+ * YAML scenarios fail inside slicetest's runtime, so their annotations point at
4
+ * the `.scenario.yaml` line here instead. The OpenAPI coverage table also goes
5
+ * to the job summary.
6
+ */
7
+ export declare const onGitHub: (env?: NodeJS.ProcessEnv) => boolean;
8
+ export interface YamlFailure {
9
+ /** Absolute path of the scenario file. */
10
+ file: string;
11
+ line: number;
12
+ scenario: string;
13
+ step: string;
14
+ message: string;
15
+ }
16
+ /** `::error file=…,line=…,title=…::message`, escaped as the runner expects. */
17
+ export declare function annotation(level: "error" | "warning", message: string, props?: {
18
+ file?: string;
19
+ line?: number;
20
+ title?: string;
21
+ }): string;
22
+ /** Paths in annotations are relative to the repository checkout. */
23
+ export declare function repoPath(file: string, env?: NodeJS.ProcessEnv): string;
24
+ /** Called in a worker: one JSON line per failed YAML step, printed by the main process at the end. */
25
+ export declare function recordYamlFailure(dir: string, failure: YamlFailure): Promise<void>;
26
+ export declare function yamlFailures(dir: string): Promise<YamlFailure[]>;
27
+ export declare function failureAnnotations(failures: YamlFailure[], env?: NodeJS.ProcessEnv): string[];
28
+ export declare function failureSummary(failures: YamlFailure[], env?: NodeJS.ProcessEnv): string;
29
+ export declare function appendSummary(markdown: string, env?: NodeJS.ProcessEnv): Promise<void>;
package/dist/ci.js ADDED
@@ -0,0 +1,57 @@
1
+ import { appendFile, readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * GitHub Actions output. Vitest already annotates failing TypeScript tests;
5
+ * YAML scenarios fail inside slicetest's runtime, so their annotations point at
6
+ * the `.scenario.yaml` line here instead. The OpenAPI coverage table also goes
7
+ * to the job summary.
8
+ */
9
+ export const onGitHub = (env = process.env) => env.GITHUB_ACTIONS === "true";
10
+ /** `::error file=…,line=…,title=…::message`, escaped as the runner expects. */
11
+ export function annotation(level, message, props = {}) {
12
+ const prop = (v) => v.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A").replace(/,/g, "%2C");
13
+ const data = message.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
14
+ const list = Object.entries(props)
15
+ .filter(([, v]) => v !== undefined && v !== "")
16
+ .map(([k, v]) => `${k}=${prop(String(v))}`)
17
+ .join(",");
18
+ return `::${level}${list ? ` ${list}` : ""}::${data}`;
19
+ }
20
+ /** Paths in annotations are relative to the repository checkout. */
21
+ export function repoPath(file, env = process.env) {
22
+ return path.relative(env.GITHUB_WORKSPACE ?? process.cwd(), file).replace(/\\/g, "/");
23
+ }
24
+ /** Called in a worker: one JSON line per failed YAML step, printed by the main process at the end. */
25
+ export async function recordYamlFailure(dir, failure) {
26
+ await appendFile(path.join(dir, `${process.pid}.jsonl`), `${JSON.stringify(failure)}\n`);
27
+ }
28
+ export async function yamlFailures(dir) {
29
+ const out = [];
30
+ for (const f of await readdir(dir).catch(() => [])) {
31
+ for (const line of (await readFile(path.join(dir, f), "utf8")).split("\n"))
32
+ if (line)
33
+ out.push(JSON.parse(line));
34
+ }
35
+ return out;
36
+ }
37
+ export function failureAnnotations(failures, env = process.env) {
38
+ return failures.map((f) => annotation("error", f.message, { file: repoPath(f.file, env), line: f.line, title: `${f.scenario}: ${f.step}` }));
39
+ }
40
+ export function failureSummary(failures, env = process.env) {
41
+ if (failures.length === 0)
42
+ return "";
43
+ const cell = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
44
+ return [
45
+ `### slicetest: ${failures.length} failed YAML step(s)`,
46
+ "",
47
+ "| Where | Scenario | Step | Error |",
48
+ "|---|---|---|---|",
49
+ ...failures.map((f) => `| \`${repoPath(f.file, env)}:${f.line}\` | ${cell(f.scenario)} | ${cell(f.step)} | ${cell(f.message.split("\n")[0].slice(0, 300))} |`),
50
+ "",
51
+ ].join("\n");
52
+ }
53
+ export async function appendSummary(markdown, env = process.env) {
54
+ if (!markdown || !env.GITHUB_STEP_SUMMARY)
55
+ return;
56
+ await appendFile(env.GITHUB_STEP_SUMMARY, `${markdown}\n`).catch(() => { });
57
+ }
package/dist/cli.js CHANGED
@@ -13,11 +13,17 @@ const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetes
13
13
  const HELP = `Usage: slicetest [filters...] [options]
14
14
  slicetest init [--force]
15
15
  slicetest gen [--spec <file>] [--out <dir>] [--uncovered] [--force]
16
+ slicetest doctor [--config <file>]
17
+ slicetest record [--out <file>] [--port <n>]
16
18
 
17
19
  Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
18
20
  \`slicetest init\` looks at the project and writes a starting config and scenario.
19
21
  \`slicetest gen\` writes scenario skeletons for the documented responses of the
20
22
  app's OpenAPI spec; with --uncovered, only for those the last run didn't produce.
23
+ \`slicetest doctor\` checks the config, the container runtime or database server,
24
+ migrations, commands and spec files, and says what to fix.
25
+ \`slicetest record\` starts everything and a proxy in front of the app: use the app
26
+ through it (a browser, curl), press Enter, and get the session as a YAML scenario.
21
27
 
22
28
  Options:
23
29
  -c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
@@ -25,6 +31,8 @@ Options:
25
31
  -t, --name <pattern> Only run scenarios whose name matches
26
32
  --spec <file> gen: OpenAPI file (default: \`openapi\` from the config)
27
33
  --out <dir> gen: where to write scenarios (default: scenarios)
34
+ record: the scenario file (default: scenarios/recorded-<time>.scenario.yaml)
35
+ --port <n> record: the proxy's port (default: any free port)
28
36
  --uncovered gen: only responses the last run didn't cover
29
37
  --force init, gen: overwrite existing files
30
38
  -h, --help Show this help
@@ -35,6 +43,7 @@ Config (paths are relative to the config file):
35
43
  stubs: [name | { name, openapi, autoReply, upstream, recordings }]
36
44
  services: { name: { command, env, cwd, ready } }
37
45
  containers: { name: { image, port, env, command, ready: { log }, reset } }
46
+ mail: true SMTP server at {{mail.host}} / {{mail.port}}
38
47
  openapi: file | { spec, minCoverage }
39
48
  http: { headers, query }
40
49
  include: [globs] default ["**/*.scenario.{yaml,yml}"]
@@ -52,6 +61,7 @@ export async function main(argv = process.argv.slice(2)) {
52
61
  spec: { type: "string" },
53
62
  out: { type: "string" },
54
63
  uncovered: { type: "boolean" },
64
+ port: { type: "string" },
55
65
  },
56
66
  });
57
67
  if (values.help) {
@@ -67,6 +77,14 @@ export async function main(argv = process.argv.slice(2)) {
67
77
  const configPath = values.config
68
78
  ? path.resolve(values.config)
69
79
  : CONFIG_NAMES.map((n) => path.resolve(n)).find((p) => existsSync(p));
80
+ if (positionals[0] === "doctor") {
81
+ const { doctor, formatChecks } = await import("./doctor.js");
82
+ const checks = await doctor(configPath);
83
+ process.stdout.write(formatChecks(checks));
84
+ if (checks.some((c) => c.status === "fail"))
85
+ process.exitCode = 1;
86
+ return;
87
+ }
70
88
  if (positionals[0] === "gen") {
71
89
  const configOpenapi = configPath && existsSync(configPath) ? (parse(await readFile(configPath, "utf8")) ?? {}).openapi : undefined;
72
90
  const spec = values.spec ?? (typeof configOpenapi === "object" ? configOpenapi.spec : configOpenapi);
@@ -89,6 +107,11 @@ export async function main(argv = process.argv.slice(2)) {
89
107
  return;
90
108
  }
91
109
  const { include, ...options } = (parse(await readFile(configPath, "utf8")) ?? {});
110
+ if (positionals[0] === "record") {
111
+ const { record } = await import("./record-cli.js");
112
+ await record(configPath, options, { out: values.out, port: values.port ? Number(values.port) : 0 });
113
+ return;
114
+ }
92
115
  const { startVitest } = await import("vitest/node");
93
116
  const { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
94
117
  const vitest = await startVitest(positionals, {
package/dist/config.d.ts CHANGED
@@ -37,6 +37,12 @@ export interface SlicetestOptions {
37
37
  * `{{container.<name>.port}}`. `reset` runs inside it before every scenario.
38
38
  */
39
39
  containers?: Record<string, ContainerOptions>;
40
+ /**
41
+ * Catch the mail the app sends. An SMTP server (no TLS, any credentials)
42
+ * listens at `{{mail.host}}` / `{{mail.port}}` (`{{mail.url}}` is `smtp://host:port`);
43
+ * scenarios read what arrived with `mail.messages()` / `mail.waitFor()`.
44
+ */
45
+ mail?: boolean;
40
46
  }
41
47
  export interface ContainerOptions {
42
48
  image: string;
@@ -102,10 +108,12 @@ export interface AppOptions {
102
108
  }
103
109
  export interface DbOptions {
104
110
  /**
105
- * `postgres` (default) or `mysql`. Inferred from `url` when it starts with `mysql://`.
111
+ * `postgres` (default), `mysql` or `sqlite`. Inferred from `url` when it starts with `mysql://`.
106
112
  * MySQL needs the `mysql2` package, and `@testcontainers/mysql` unless `url` is given.
113
+ * SQLite needs Node.js 22.5+ and nothing else: no server, no container. The app gets
114
+ * `{{db.url}}` as `sqlite:///path/to/file.db` and `{{db.path}}` as the file path.
107
115
  */
108
- engine?: "postgres" | "mysql";
116
+ engine?: "postgres" | "mysql" | "sqlite";
109
117
  /** Image used when no `url` is given. Default `postgres:17-alpine`, or `mysql:8.4` for MySQL. */
110
118
  image?: string;
111
119
  /**
@@ -151,6 +159,7 @@ export interface ResolvedOptions {
151
159
  };
152
160
  services: Record<string, ResolvedProcess>;
153
161
  containers: Record<string, ContainerOptions>;
162
+ mail: boolean;
154
163
  db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">;
155
164
  stubs: string[];
156
165
  /** Spec files, resolved against the root: the app's, and per stub name. */
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ export function resolveOptions(opts, root) {
5
5
  app: { ...opts.app, ready: resolveReady(opts.app.ready ?? { path: "/" }) },
6
6
  services: Object.fromEntries(Object.entries(opts.services ?? {}).map(([name, s]) => [name, { ...s, ready: s.ready && resolveReady(s.ready) }])),
7
7
  containers: opts.containers ?? {},
8
+ mail: opts.mail ?? false,
8
9
  db: resolveDb(opts.db ?? {}),
9
10
  stubs: (opts.stubs ?? []).map(stubName),
10
11
  openapi: {
@@ -39,9 +40,9 @@ function resolveDb(db) {
39
40
  const env = process.env.SLICETEST_DATABASE_URL || undefined;
40
41
  const engine = db.engine ?? (isMysql(db.url ?? env ?? "") ? "mysql" : "postgres");
41
42
  // The environment variable names one server for the whole CI job; a project on the other engine starts its own.
42
- const url = db.url ?? (env && isMysql(env) === (engine === "mysql") ? env : undefined);
43
+ const url = engine === "sqlite" ? undefined : (db.url ?? (env && isMysql(env) === (engine === "mysql") ? env : undefined));
43
44
  return {
44
- image: engine === "mysql" ? "mysql:8.4" : "postgres:17-alpine",
45
+ image: engine === "mysql" ? "mysql:8.4" : engine === "sqlite" ? "" : "postgres:17-alpine",
45
46
  schemas: ["public"],
46
47
  keep: [],
47
48
  ...db,
@@ -84,9 +85,13 @@ function validate(opts) {
84
85
  fail(`containers.${name}.${key} must be a list of strings, e.g. ["redis-cli", "FLUSHALL"]`);
85
86
  }
86
87
  }
88
+ if (opts.mail !== undefined && typeof opts.mail !== "boolean")
89
+ fail(`mail must be true or false, got ${JSON.stringify(opts.mail)}`);
87
90
  const engine = opts.db?.engine;
88
- if (engine !== undefined && engine !== "postgres" && engine !== "mysql")
89
- fail(`db.engine must be "postgres" or "mysql", got ${JSON.stringify(engine)}`);
91
+ if (engine !== undefined && engine !== "postgres" && engine !== "mysql" && engine !== "sqlite")
92
+ fail(`db.engine must be "postgres", "mysql" or "sqlite", got ${JSON.stringify(engine)}`);
93
+ if (engine === "sqlite" && opts.db?.url)
94
+ fail("db.url doesn't apply to sqlite: slicetest creates the database files itself and passes them to the app as {{db.url}} / {{db.path}}");
90
95
  const migrate = opts.db?.migrate;
91
96
  if (migrate) {
92
97
  const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
@@ -0,0 +1,22 @@
1
+ import { type ResolvedOptions } from "./config.js";
2
+ /**
3
+ * `slicetest doctor`: everything a run needs, checked up front, each problem
4
+ * with what to do about it. Meant for a first setup and for CI logs, where a
5
+ * container runtime that isn't there otherwise shows up as a timeout.
6
+ */
7
+ export interface Check {
8
+ status: "ok" | "warn" | "fail";
9
+ label: string;
10
+ /** What was found, or what to do. */
11
+ detail?: string;
12
+ }
13
+ /** Things that touch the machine, replaceable in tests. */
14
+ export interface Probes {
15
+ containerRuntime(): Promise<string>;
16
+ database(opts: ResolvedOptions, url: string): Promise<void>;
17
+ command(name: string, args: string[]): Promise<string>;
18
+ resolvePackage(name: string, from: string): boolean;
19
+ }
20
+ export declare const machine: Probes;
21
+ export declare function doctor(configPath: string | undefined, probes?: Probes, env?: NodeJS.ProcessEnv): Promise<Check[]>;
22
+ export declare function formatChecks(checks: Check[]): string;
package/dist/doctor.js ADDED
@@ -0,0 +1,190 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { parse } from "yaml";
8
+ import { resolveOptions } from "./config.js";
9
+ import { configureContainerRuntime } from "./container-runtime.js";
10
+ import { engineFor } from "./drivers/index.js";
11
+ import { OpenApiSpec } from "./openapi.js";
12
+ const exec = promisify(execFile);
13
+ export const machine = {
14
+ async containerRuntime() {
15
+ configureContainerRuntime();
16
+ const { getContainerRuntimeClient } = await import("testcontainers");
17
+ const client = await withTimeout(getContainerRuntimeClient(), 15_000, "no answer from the container runtime");
18
+ const { containerRuntime: rt } = client.info;
19
+ return `${rt.operatingSystem} ${rt.serverVersion} at ${rt.host}`;
20
+ },
21
+ async database(opts, url) {
22
+ const admin = await withTimeout((await engineFor(opts)).admin(url), 10_000, "connection timed out");
23
+ await admin.close();
24
+ },
25
+ async command(name, args) {
26
+ const { stdout, stderr } = await exec(name, args, { windowsHide: true, timeout: 10_000, shell: process.platform === "win32" });
27
+ return (stdout || stderr).trim().split(/\r?\n/)[0];
28
+ },
29
+ resolvePackage(name, from) {
30
+ try {
31
+ createRequire(path.join(from, "noop.js")).resolve(`${name}/package.json`);
32
+ return true;
33
+ }
34
+ catch {
35
+ try {
36
+ createRequire(import.meta.url).resolve(name);
37
+ return true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ },
44
+ };
45
+ export async function doctor(configPath, probes = machine, env = process.env) {
46
+ const checks = [];
47
+ const add = (status, label, detail) => checks.push({ status, label, ...(detail ? { detail } : {}) });
48
+ const major = Number(process.versions.node.split(".")[0]);
49
+ add(major >= 20 ? "ok" : "fail", `Node.js ${process.versions.node}`, major >= 20 ? undefined : "slicetest needs Node.js 20 or later");
50
+ if (!configPath || !existsSync(configPath)) {
51
+ add("warn", "no slicetest.config.yaml", "checked the machine only. `npx slicetest init` writes a config; with the Vitest plugin, pass the same options to --config as YAML to check them");
52
+ await checkContainerRuntime(add, probes);
53
+ return checks;
54
+ }
55
+ const root = path.dirname(configPath);
56
+ const rel = (p) => path.relative(process.cwd(), path.resolve(root, p)) || ".";
57
+ let opts;
58
+ try {
59
+ const raw = (parse(await readFile(configPath, "utf8")) ?? {});
60
+ delete raw.include;
61
+ opts = resolveOptions(raw, root);
62
+ add("ok", `config ${rel(configPath)}`);
63
+ }
64
+ catch (e) {
65
+ add("fail", `config ${rel(configPath)}`, e.message.replace(/^slicetest: /, ""));
66
+ return checks;
67
+ }
68
+ // Database: a server that's there already, or a container runtime to start one in.
69
+ const url = opts.db.engine === "sqlite" ? undefined : (opts.db.url ?? env.SLICETEST_DATABASE_URL);
70
+ if (opts.db.engine === "sqlite") {
71
+ const [maj = 0, min = 0] = process.versions.node.split(".").map(Number);
72
+ if (maj > 22 || (maj === 22 && min >= 5))
73
+ add("ok", "sqlite (node:sqlite, no server needed)");
74
+ else
75
+ add("fail", "sqlite needs Node.js 22.5 or later", `this is ${process.versions.node}; slicetest uses the built-in node:sqlite`);
76
+ if (Object.keys(opts.containers).length)
77
+ await checkContainerRuntime(add, probes, `runs ${Object.values(opts.containers).map((c) => c.image).join(", ")}`);
78
+ }
79
+ else if (url) {
80
+ try {
81
+ await probes.database(opts, url);
82
+ add("ok", `${opts.db.engine} at ${redact(url)}`);
83
+ }
84
+ catch (e) {
85
+ add("fail", `${opts.db.engine} at ${redact(url)}`, `can't connect: ${e.message}. It must be a superuser (root) connection that can create databases`);
86
+ }
87
+ }
88
+ else {
89
+ await checkContainerRuntime(add, probes, `runs ${[opts.db.image, ...Object.values(opts.containers).map((c) => c.image)].join(", ")}`);
90
+ }
91
+ if (opts.db.engine === "mysql") {
92
+ for (const pkg of ["mysql2", ...(url ? [] : ["@testcontainers/mysql"])]) {
93
+ if (probes.resolvePackage(pkg, root))
94
+ add("ok", `${pkg} installed`);
95
+ else
96
+ add("fail", `${pkg} not installed`, `db.engine "mysql" needs it: npm i -D ${pkg}`);
97
+ }
98
+ }
99
+ const m = opts.db.migrate;
100
+ if (!m)
101
+ add("warn", "db.migrate not set", "scenarios run against an empty database unless the app creates its own tables");
102
+ else if ("atlas" in m) {
103
+ await checkPath(add, "migrations", m.atlas.dir.replace(/^file:\/\//, ""), root, rel);
104
+ try {
105
+ add("ok", `atlas CLI (${await probes.command("atlas", ["version"])})`);
106
+ }
107
+ catch {
108
+ add("fail", "atlas CLI not found", "db.migrate.atlas runs `atlas migrate apply`. Install it: https://atlasgo.io/getting-started");
109
+ }
110
+ }
111
+ else if ("sql" in m)
112
+ await checkPath(add, "migrations", m.sql, root, rel);
113
+ else
114
+ for (const input of m.inputs ?? [])
115
+ await checkPath(add, "migration input", input, root, rel);
116
+ if (opts.db.seed)
117
+ await checkPath(add, "seed", opts.db.seed, root, rel);
118
+ for (const [label, p] of [["app", opts.app], ...Object.entries(opts.services).map(([n, s]) => [`service ${n}`, s])]) {
119
+ if (p.cwd)
120
+ await checkPath(add, `${label} cwd`, p.cwd, root, rel);
121
+ const program = firstWord(p.command);
122
+ if (program && !/^[.\\/]|\{\{|\$/.test(program)) {
123
+ if (await onPath(program, env))
124
+ add("ok", `${label}: ${program} found`);
125
+ else
126
+ add("warn", `${label}: ${program} not found on PATH`, `\`${p.command}\` may fail to start. Fine if a shell alias or a relative script provides it`);
127
+ }
128
+ }
129
+ const specs = [...(opts.openapi.app ? [["app OpenAPI", opts.openapi.app]] : []), ...Object.entries(opts.openapi.stubs).map(([n, f]) => [`stub ${n} OpenAPI`, f])];
130
+ for (const [label, file] of specs) {
131
+ try {
132
+ const spec = await OpenApiSpec.load(path.resolve(root, file), file);
133
+ add("ok", `${label} ${rel(file)}`, `${spec.operations().length} operation(s)`);
134
+ }
135
+ catch (e) {
136
+ add("fail", `${label} ${rel(file)}`, e.message.replace(/^slicetest: /, ""));
137
+ }
138
+ }
139
+ for (const [name, r] of Object.entries(opts.recordings)) {
140
+ if (existsSync(r.file))
141
+ add("ok", `stub ${name}: recordings ${rel(r.file)}`);
142
+ else
143
+ add("warn", `stub ${name}: no recordings yet (${rel(r.file)})`, `run once with SLICETEST_RECORD=${name} to record ${r.upstream}`);
144
+ }
145
+ return checks;
146
+ }
147
+ async function checkContainerRuntime(add, probes, forWhat) {
148
+ try {
149
+ add("ok", `container runtime: ${await probes.containerRuntime()}`, forWhat);
150
+ }
151
+ catch (e) {
152
+ add("fail", "no container runtime", `${e.message.split("\n")[0]}. Start Docker or a Podman machine (\`podman machine start\`), or set SLICETEST_DATABASE_URL / db.url to an existing database server`);
153
+ }
154
+ }
155
+ async function checkPath(add, label, p, root, rel) {
156
+ try {
157
+ await stat(path.resolve(root, p));
158
+ add("ok", `${label} ${rel(p)}`);
159
+ }
160
+ catch {
161
+ add("fail", `${label} ${rel(p)} not found`, `paths in the config are relative to the config file's directory (${rel(".")})`);
162
+ }
163
+ }
164
+ function firstWord(command) {
165
+ return /^\s*(?:"([^"]+)"|(\S+))/.exec(command)?.slice(1).find(Boolean);
166
+ }
167
+ async function onPath(program, env) {
168
+ const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
169
+ for (const dir of (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean)) {
170
+ for (const ext of exts) {
171
+ if (existsSync(path.join(dir, program + ext)) || existsSync(path.join(dir, program)))
172
+ return true;
173
+ }
174
+ }
175
+ return false;
176
+ }
177
+ function redact(url) {
178
+ return url.replace(/\/\/([^:/@]+):[^@]*@/, "//$1:***@");
179
+ }
180
+ function withTimeout(p, ms, message) {
181
+ return Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms).unref())]);
182
+ }
183
+ export function formatChecks(checks) {
184
+ const icon = { ok: "✓", warn: "!", fail: "✗" };
185
+ const lines = checks.map((c) => ` ${icon[c.status]} ${c.label}${c.detail ? `\n ${c.detail}` : ""}`);
186
+ const fails = checks.filter((c) => c.status === "fail").length;
187
+ const warns = checks.filter((c) => c.status === "warn").length;
188
+ const summary = fails ? `${fails} problem(s) to fix before running.` : warns ? `Ready to run, with ${warns} warning(s).` : "Ready to run.";
189
+ return `${lines.join("\n")}\n\n${summary}\n`;
190
+ }