slicetest 0.8.0 → 0.9.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
@@ -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`, or Bun, pnpm or Yarn from the lockfile), Deno, Phoenix, Django, FastAPI, Flask, Rails, Laravel, Symfony, plain PHP, Spring Boot, ASP.NET Core, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Laravel, Doctrine, EF Core, Ecto, 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. Token issuer settings there (`OIDC_ISSUER`, `AUTH0_DOMAIN`, `JWKS_URL`, `JWT_AUDIENCE`, …) turn on [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) and point at slicetest's issuer instead of becoming stubs. It lists every guess as a comment in the config so you know what to check.
41
+ `init` recognises Node (`npm start`, or Bun, pnpm or Yarn from the lockfile), Deno, Phoenix, Django, FastAPI, Flask, Rails, Laravel, Symfony, plain PHP, Spring Boot, ASP.NET Core, Go (the main package at the root or in `cmd/<name>`) and Rust apps (both compiled once before the workers start); Atlas, Prisma, Alembic, Django, Rails, Laravel, Doctrine, EF Core, Ecto, Drizzle, Knex and plain SQL migrations (golang-migrate, goose, sqlx, Diesel and dbmate layouts in `migrations/`, `db/migrations/` or `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. Token issuer settings there (`OIDC_ISSUER`, `AUTH0_DOMAIN`, `JWKS_URL`, `JWT_AUDIENCE`, …) turn on [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) and point at slicetest's issuer instead of becoming stubs. 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
 
@@ -294,7 +294,7 @@ stub("slack").calls("POST", "/hook"); // recorded calls:
294
294
 
295
295
  Form-encoded bodies (Stripe, Twilio, OAuth token requests) are parsed into `call.form`, with bracket keys nested the way those providers read them: `metadata[order]=7&items[0][price]=p_1` is `{ metadata: { order: "7" }, items: [{ price: "p_1" }] }`. `form` conditions match a subset of it, and numbers and booleans compare with the strings sent. `multipart/form-data` bodies are read the same way, with each file as `{ filename, type, size, text }` (`text` for text, JSON, XML and CSV files), so `form: { avatar: { filename: "a.png", type: "image/png" } }` checks an upload the app passed on.
296
296
 
297
- Later routes win. `path` may also be a RegExp, and `method` may be `*`. Unanswered calls get a `501` and fail the scenario, with the closest route and why it didn't match (`stub.explain(call)`).
297
+ Later routes win. `path` may also be a RegExp, and `method` may be `*`. A query written into the path (`on("GET", "/search?q=tea")`, also in `calls()`, `toHaveReceived()` and YAML stubs) is a condition on those parameters, like `query: { q: "tea" }`; other parameters may come along. A list matches a repeated parameter's values in order: `query: { ids: ["1", "2"] }` for `?ids=1&ids=2`. Unanswered calls get a `501` and fail the scenario, with the closest route and why it didn't match (`stub.explain(call)`).
298
298
 
299
299
  ### OpenAPI contracts — for your app and for the services you stub
300
300
 
@@ -465,7 +465,7 @@ Record once, with real credentials in the app's environment:
465
465
  SLICETEST_RECORD=github npx vitest # or SLICETEST_RECORD=1 for every stub with an upstream
466
466
  ```
467
467
 
468
- Calls no route matches are forwarded to `upstream` (under its path prefix, headers included) and the answers are written to `recordings/github.yaml` (`recordings:` changes the path). Later runs replay them without touching the network. A request is identified by method, path, query and body (JSON key order doesn't matter); identical requests replay their recordings in the order they were made. Only `content-type`, `location`, `retry-after`, `link` and `etag` response headers are kept, and request headers are never stored, so tokens stay out of the file; bodies are stored as sent, so review the file before committing it.
468
+ Calls no route matches are forwarded to `upstream` (under its path prefix, headers included) and the answers are written to `recordings/github.yaml` (`recordings:` changes the path). Later runs replay them without touching the network. A request is identified by method, path, query and body (JSON key order doesn't matter); identical requests replay their recordings in the order they were made. Only `content-type`, `location`, `retry-after`, `link` and `etag` response headers are kept, and request headers are never stored, so tokens stay out of the file. Credentials sent in the query or the body (`api_key`, `key` in the query, `appid`, `access_token`, `client_secret`, `password`, signatures, …) are stored as `[redacted]` and match any value on replay, so the test environment's dummy key replays what the real key recorded. Other values are stored as sent, so review the file before committing it.
469
469
 
470
470
  #### From a HAR file: `npx slicetest import`
471
471
 
@@ -486,6 +486,10 @@ Registered automatically:
486
486
 
487
487
  ```ts
488
488
  expect(res).toHaveStatus(201); // failure shows the response body
489
+ expect(res).toHaveStatus("2xx"); // a class, or a list: [200, 204], ["2xx", 304]
490
+ expect(res).toRespondWithin(300); // a response-time budget, in ms
491
+ expect(res).toSetCookie("sid", { httpOnly: true, secure: true, sameSite: "Lax" }); // a cookie and its attributes
492
+ expect(res.events).toContainEqual({ event: "message_stop", data: {} }); // text/event-stream responses are parsed into events
489
493
  expect(responses).toHaveStatuses({ 201: 1, 409: 9 }); // an array, e.g. from http.concurrently()
490
494
  expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "hi" } });
491
495
  expect(stub("slack")).toHaveReceivedTimes(1, "POST", "/hook");
@@ -725,7 +729,7 @@ Tags select scenarios across files: `npx slicetest --tag smoke` (repeat `--tag`
725
729
  | `app.readyTimeout` | `30000` | |
726
730
  | `app.scope` | `"file"` | `"worker"`: start the app (and stubs, services) once per Vitest worker and keep it for all of that worker's test files, for apps that start slowly. Sets Vitest's `isolate: false`. |
727
731
  | `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
728
- | `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs?, env? }`. The command gets `DATABASE_URL`, and both it and `env` may use the `{{db.*}}` placeholders, for tools that read other variables: `{ command: "php artisan migrate --force", env: { DB_HOST: "{{db.host}}", DB_DATABASE: "{{db.name}}" } }`, `{ command: "dotnet ef database update --connection \"{{db.adoNet}}\"" }`. |
732
+ | `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs?, env? }`. A `sql` directory is applied in name order with version numbers compared as numbers (`V2__` before `V10__`), leaving out rollbacks: `*.down.sql` (golang-migrate, sqlx, Diesel), Flyway undo files (`U2__…`) and the down section of goose (`-- +goose Down`) and dbmate (`-- migrate:down`) files. The command gets `DATABASE_URL`, and both it and `env` may use the `{{db.*}}` placeholders, for tools that read other variables: `{ command: "php artisan migrate --force", env: { DB_HOST: "{{db.host}}", DB_DATABASE: "{{db.name}}" } }`, `{ command: "dotnet ef database update --connection \"{{db.adoNet}}\"" }`. |
729
733
  | `db.seed` | none | SQL file re-run after every reset. |
730
734
  | `db.schemas` | `["public"]` | Schemas whose tables are reset. |
731
735
  | `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
@@ -795,7 +799,7 @@ scenarios:
795
799
  | Step | Keys |
796
800
  |---|---|
797
801
  | `stub: <name>` | `on: METHOD /path` (`:params` allowed) or `graphql: <operation>`, `when: { query, headers, json, form, body, variables }`, one of `reply: { status, headers, body }` (`{ file }` for a fixture file, `{ data, errors }` for GraphQL) / `sequence: [...]` / `networkError: true`, plus `times`, `delay`. Replies may echo the call: `{{call.params.id}}`, `{{call.json.name}}`, `{{call.form.amount}}`, `{{call.variables.id}}`. |
798
- | `submit: <button>` | `form`, `fields` (`{ file: path }` for a file input), `headers`, `follow`, `expect: { status, headers, json, text }`, `capture`. Submits a form of the page the last request returned, like `http.submit()`; `submit: true` presses the form's only button. |
802
+ | `submit: <button>` | `form`, `fields` (`{ file: path }` for a file input), `headers`, `follow`, `expect: { status, headers, json, text }` (`status` may be a class, `2xx`, or a list, `[200, 204]`; `duration: 300` fails a response slower than 300 ms, `duration: { $lt: 300 }` takes matchers; `cookies: { sid: { httpOnly: true, sameSite: Lax }, tracking: null }` checks the cookies it sets and their attributes, `null` for one it must not set; `events: [{ event: message_start }, { data: "[DONE]" }]` checks a `text/event-stream` response's events in order, others allowed between), `capture` (also from `events.0.data.id`). Submits a form of the page the last request returned, like `http.submit()`; `submit: true` presses the form's only button. |
799
803
  | `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `multipart` / `body` / `graphql: { query, variables, operationName }`, `expect.schema` (a JSON Schema, or `../openapi.yaml#/components/schemas/Poll` relative to the file), `follow`, `expect: { status, headers, json, text }`, `capture`. `timeout: <ms>` fails it, by name, when the app doesn't answer in time. `concurrency: n` sends it `n` times at once; `expect` then applies to each response, and `expect.statuses: { 201: 1, 409: 9 }` counts them. |
800
804
  | `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
801
805
  | `request` with `auth` | `auth: true` or the claims: sends a bearer token from the `auth` issuer |
@@ -838,7 +842,8 @@ scenarios:
838
842
  | `{ $type: number }` | `string`, `number`, `integer`, `boolean`, `array`, `object`, `null` |
839
843
  | `{ $regex: "^ch_" }` | a string the pattern finds |
840
844
  | `{ $contains: "ok" }` | a string with that substring, or a list with a matching item (`{ $contains: { sku: a } }`) |
841
- | `{ $gte: 1, $lt: 10 }` | numbers, or strings such as ISO dates (`{ $gte: "2026-01-01" }`); also `$gt`, `$lte` |
845
+ | `{ $gte: 1, $lt: 10 }` | numbers (also decimals that `numeric` / `DECIMAL` columns return as strings, `"12.50"`), or strings such as ISO dates (`{ $gte: "2026-01-01" }`); also `$gt`, `$lte` |
846
+ | `{ $closeTo: 9.99 }` | a number within ±0.005 (`[9.99, 0.1]` for another tolerance), for floats and computed totals; decimal strings too |
842
847
  | `{ $len: 3 }` | a string or list of that length; `{ $len: { $gte: 1 } }` |
843
848
  | `{ $oneOf: [paid, pending] }` | any of the values (or matchers) |
844
849
  | `{ $not: "" }` | anything the value or matcher doesn't match |
@@ -897,6 +902,9 @@ npx slicetest # every *.scenario.yaml under the config's directo
897
902
  npx slicetest polls -t voting # filter by file and scenario name
898
903
  npx slicetest --watch
899
904
  npx slicetest list --tag smoke # what would run: file, line, tags, steps (--json for tools)
905
+ npx slicetest -u # rewrite `snapshot: true` snapshots that no longer match
906
+ npx slicetest --reporter junit --output-file reports/slicetest.xml # GitLab, Jenkins, CircleCI test reports
907
+ npx slicetest --shard 2/4 # the second of four parts, for parallel CI jobs
900
908
  ```
901
909
 
902
910
  ### Scenarios from your OpenAPI spec: `npx slicetest gen`
@@ -0,0 +1,13 @@
1
+ export interface RunFlags {
2
+ update?: boolean;
3
+ reporter?: string[];
4
+ "output-file"?: string;
5
+ shard?: string;
6
+ }
7
+ /** Vitest options for the run flags of `npx slicetest`; paths are taken from where the command runs, like `--diagrams`. */
8
+ export declare function runOptions(flags: RunFlags, cwd?: string): {
9
+ update?: boolean | undefined;
10
+ reporters?: string[] | undefined;
11
+ outputFile?: string | undefined;
12
+ shard?: string | undefined;
13
+ };
@@ -0,0 +1,15 @@
1
+ import path from "node:path";
2
+ /** Vitest options for the run flags of `npx slicetest`; paths are taken from where the command runs, like `--diagrams`. */
3
+ export function runOptions(flags, cwd = process.cwd()) {
4
+ if (flags.shard !== undefined) {
5
+ const m = /^(\d+)\/(\d+)$/.exec(flags.shard);
6
+ if (!m || Number(m[1]) < 1 || Number(m[1]) > Number(m[2]))
7
+ throw new Error(`slicetest: --shard takes <index>/<count> with 1 ≤ index ≤ count, e.g. --shard 1/3, got "${flags.shard}"`);
8
+ }
9
+ return {
10
+ ...(flags.update ? { update: true } : {}),
11
+ ...(flags.reporter?.length ? { reporters: flags.reporter } : {}),
12
+ ...(flags["output-file"] ? { outputFile: path.resolve(cwd, flags["output-file"]) } : {}),
13
+ ...(flags.shard ? { shard: flags.shard } : {}),
14
+ };
15
+ }
package/dist/cli.js CHANGED
@@ -46,6 +46,11 @@ Options:
46
46
  --upstream <url> import: the real service's base URL for --stub
47
47
  --uncovered gen: only responses the last run didn't cover
48
48
  --force init, gen: overwrite existing files
49
+ -u, --update Rewrite snapshots (\`snapshot: true\` steps) that no longer match
50
+ --reporter <name> Vitest reporter: default, verbose, dot, junit, json, tap,
51
+ github-actions (repeat for several)
52
+ --output-file <file> Where junit / json / tap reporters write
53
+ --shard <i/n> Run the i-th of n parts of the suite (split CI jobs)
49
54
  --diagrams <dir> Write a Mermaid sequence diagram of every scenario to <dir>,
50
55
  one Markdown page per scenario file
51
56
  -h, --help Show this help
@@ -83,6 +88,10 @@ export async function main(argv = process.argv.slice(2)) {
83
88
  stub: { type: "string" },
84
89
  upstream: { type: "string" },
85
90
  json: { type: "boolean" },
91
+ update: { type: "boolean", short: "u" },
92
+ reporter: { type: "string", multiple: true },
93
+ "output-file": { type: "string" },
94
+ shard: { type: "string" },
86
95
  },
87
96
  });
88
97
  if (values.help) {
@@ -190,6 +199,8 @@ export async function main(argv = process.argv.slice(2)) {
190
199
  const { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
191
200
  // Vitest 4 takes the mode ("test") first; 5 dropped it.
192
201
  const start = (Number.parseInt(version, 10) === 4 ? startVitest.bind(null, "test") : startVitest);
202
+ const { runOptions } = await import("./cli-run.js");
203
+ const run = runOptions(values);
193
204
  const vitest = await start(positionals, {
194
205
  config: false,
195
206
  root: path.dirname(configPath),
@@ -197,6 +208,7 @@ export async function main(argv = process.argv.slice(2)) {
197
208
  watch: !!values.watch,
198
209
  run: !values.watch,
199
210
  testNamePattern: values.name,
211
+ ...run,
200
212
  }, { plugins: [slicetest(options)] });
201
213
  if (!values.watch)
202
214
  await vitest?.close();
package/dist/config.js CHANGED
@@ -59,10 +59,55 @@ function resolveDb(db) {
59
59
  reuse: db.reuse ?? (!url && !process.env.CI),
60
60
  };
61
61
  }
62
+ const TOP_LEVEL_KEYS = ["app", "db", "stubs", "openapi", "http", "services", "containers", "mail", "offline", "strictStubs", "workers", "auth", "include"];
63
+ const APP_KEYS = ["command", "build", "cwd", "env", "ready", "readyTimeout", "scope", "baseEnv"];
64
+ const STUB_KEYS = ["name", "openapi", "autoReply", "upstream", "recordings", "hosts"];
65
+ const CONTAINER_KEYS = ["image", "port", "env", "command", "ready", "reset"];
66
+ const DB_KEYS = ["engine", "image", "url", "migrate", "seed", "schemas", "keep", "ignoreChanges", "reuse", "queries", "neon"];
67
+ function editDistance(a, b) {
68
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
69
+ for (let i = 1; i <= a.length; i++) {
70
+ let prev = row[0];
71
+ row[0] = i;
72
+ for (let j = 1; j <= b.length; j++) {
73
+ const tmp = row[j];
74
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
75
+ prev = tmp;
76
+ }
77
+ }
78
+ return row[b.length];
79
+ }
62
80
  function validate(opts) {
63
81
  const fail = (msg) => {
64
82
  throw new Error(`slicetest: invalid config: ${msg}`);
65
83
  };
84
+ // A misspelt key (`stub:`, `strictstubs:`) would otherwise be ignored without a word; YAML configs have no type checker.
85
+ const checkKeys = (value, allowed, where) => {
86
+ if (!value || typeof value !== "object" || Array.isArray(value))
87
+ return;
88
+ for (const key of Object.keys(value)) {
89
+ if (allowed.includes(key) || key === "$schema")
90
+ continue;
91
+ // Close in spelling, or sharing the first four letters (`servers` / `services`, `migrations` / `migrate`).
92
+ const near = allowed
93
+ .map((k) => ({ k, d: editDistance(k.toLowerCase(), key.toLowerCase()) }))
94
+ .filter(({ k, d }) => d <= Math.max(2, Math.floor(key.length / 3)) || (key.length >= 4 && k.toLowerCase().startsWith(key.slice(0, 4).toLowerCase())))
95
+ .sort((a, b) => a.d - b.d)[0];
96
+ const hint = near ? `; did you mean "${near.k}"?` : ` (expected ${allowed.join(", ")})`;
97
+ fail(`unknown key ${where}${key}${hint}`);
98
+ }
99
+ };
100
+ checkKeys(opts, TOP_LEVEL_KEYS, "");
101
+ checkKeys(opts?.app, APP_KEYS, "app.");
102
+ if (opts?.db)
103
+ checkKeys(opts.db, DB_KEYS, "db.");
104
+ for (const stub of opts?.stubs ?? [])
105
+ if (typeof stub === "object" && stub)
106
+ checkKeys(stub, STUB_KEYS, `stubs.${stub.name ?? "?"}.`);
107
+ for (const [name, service] of Object.entries(opts?.services ?? {}))
108
+ checkKeys(service, APP_KEYS, `services.${name}.`);
109
+ for (const [name, container] of Object.entries(opts?.containers ?? {}))
110
+ checkKeys(container, CONTAINER_KEYS, `containers.${name}.`);
66
111
  if (!opts?.app || typeof opts.app.command !== "string" || !opts.app.command.trim()) {
67
112
  fail("app.command is required, e.g. { app: { command: \"node server.js\" } }");
68
113
  }
package/dist/form.d.ts CHANGED
@@ -34,6 +34,7 @@ interface Control {
34
34
  options: {
35
35
  value: string;
36
36
  selected: boolean;
37
+ disabled: boolean;
37
38
  }[];
38
39
  }
39
40
  interface ParsedForm {
package/dist/form.js CHANGED
@@ -37,6 +37,7 @@ export function parseForms(html) {
37
37
  let open;
38
38
  let select;
39
39
  let option;
40
+ let groupDisabled = false;
40
41
  // Controls inside a disabled <fieldset> are disabled too (and not submitted).
41
42
  const fieldsets = [];
42
43
  const add = (control) => {
@@ -52,7 +53,7 @@ export function parseForms(html) {
52
53
  if (!option || !select)
53
54
  return;
54
55
  const value = option.attrs.value ?? textOf(html.slice(option.start, end));
55
- select.options.push({ value, selected: "selected" in option.attrs });
56
+ select.options.push({ value, selected: "selected" in option.attrs, disabled: "disabled" in option.attrs || groupDisabled });
56
57
  option = undefined;
57
58
  };
58
59
  TAG.lastIndex = 0;
@@ -107,6 +108,7 @@ export function parseForms(html) {
107
108
  if (closing) {
108
109
  closeOption(m.index);
109
110
  select = undefined;
111
+ groupDisabled = false;
110
112
  }
111
113
  else {
112
114
  select = { tag, attrs: parseAttrs(m[3] ?? ""), text: "", options: [] };
@@ -116,6 +118,8 @@ export function parseForms(html) {
116
118
  }
117
119
  if (tag === "option" || tag === "optgroup") {
118
120
  closeOption(m.index);
121
+ if (tag === "optgroup")
122
+ groupDisabled = !closing && "disabled" in parseAttrs(m[3] ?? "");
119
123
  if (tag === "option" && !closing && select)
120
124
  option = { attrs: parseAttrs(m[3] ?? ""), start: TAG.lastIndex };
121
125
  continue;
@@ -238,10 +242,15 @@ export function formRequest(html, opts = {}) {
238
242
  else if (c.tag === "textarea")
239
243
  entries.push([name, c.text]);
240
244
  else if (c.tag === "select") {
241
- const selected = c.options.filter((o) => o.selected);
242
- const chosen = selected.length ? selected : "multiple" in c.attrs || c.options.length === 0 ? [] : [c.options[0]];
245
+ // With nothing selected, a single select shows its first option that isn't disabled; disabled options are never sent.
246
+ // A single select keeps only the last option marked selected.
247
+ const marked = c.options.filter((o) => o.selected);
248
+ const selected = "multiple" in c.attrs ? marked : marked.slice(-1);
249
+ const first = c.options.find((o) => !o.disabled);
250
+ const chosen = selected.length ? selected : "multiple" in c.attrs || !first ? [] : [first];
243
251
  for (const o of chosen)
244
- entries.push([name, o.value]);
252
+ if (!o.disabled)
253
+ entries.push([name, o.value]);
245
254
  }
246
255
  else
247
256
  entries.push([name, c.attrs.value ?? ""]);
@@ -12,6 +12,7 @@ import { engineFor } from "./drivers/index.js";
12
12
  import { coverageCacheFile } from "./gen.js";
13
13
  import { appSpecFile, formatCoverage, formatUsage, OpenApiSpec } from "./openapi.js";
14
14
  import { mergeRecordings } from "./recording.js";
15
+ import { applySqlFiles, sqlMigrationFiles } from "./sql-migrations.js";
15
16
  import "./provided.js";
16
17
  const exec = promisify(execFile);
17
18
  /** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
@@ -314,14 +315,10 @@ async function applyMigrations(opts, engine, url, m) {
314
315
  return run("atlas", ["migrate", "apply", "--url", engine.atlasUrl(url), "--dir", dir], opts.root);
315
316
  }
316
317
  else if ("sql" in m) {
317
- const target = path.resolve(opts.root, m.sql);
318
- const files = (await stat(target)).isDirectory()
319
- ? (await readdir(target)).filter((f) => f.endsWith(".sql")).sort().map((f) => path.join(target, f))
320
- : [target];
318
+ const files = await sqlMigrationFiles(path.resolve(opts.root, m.sql));
321
319
  const driver = await engine.driver(url);
322
320
  try {
323
- for (const file of files)
324
- await driver.exec(await readFile(file, "utf8"));
321
+ await applySqlFiles(files, (sql) => driver.exec(sql), opts.root);
325
322
  }
326
323
  finally {
327
324
  await driver.close();
package/dist/har.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { mergeRecordings } from "./recording.js";
3
+ import { mergeRecordings, redactRequest } from "./recording.js";
4
4
  const KEPT_HEADERS = ["content-type", "location", "retry-after", "link", "etag"];
5
5
  const TEXTUAL = /json|text|xml|javascript|x-www-form-urlencoded|graphql/i;
6
6
  export function harEntries(har, file = "the HAR file") {
@@ -29,12 +29,12 @@ export function toRecording(entry, upstream) {
29
29
  const query = Object.fromEntries([...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)));
30
30
  const sent = entry.request.postData?.text;
31
31
  const json = parse(sent);
32
- const request = {
32
+ const request = redactRequest({
33
33
  method,
34
34
  path: url.pathname.slice(prefix.length) || "/",
35
35
  ...(Object.keys(query).length ? { query } : {}),
36
36
  ...(json !== undefined ? { json } : sent ? { body: sent } : {}),
37
- };
37
+ });
38
38
  const content = entry.response.content ?? {};
39
39
  let text = content.text ?? "";
40
40
  if (content.encoding === "base64" && text) {
package/dist/http.d.ts CHANGED
@@ -10,7 +10,19 @@ export interface HttpResponse {
10
10
  json: any;
11
11
  /** Wall-clock time of the request in milliseconds. */
12
12
  durationMs: number;
13
+ /** The events of a `text/event-stream` response (data parsed as JSON when it is JSON); undefined for other responses. */
14
+ events?: ServerEvent[];
13
15
  }
16
+ /** One server-sent event of a response. */
17
+ export interface ServerEvent {
18
+ /** The `event:` field; undefined for unnamed (`message`) events. */
19
+ event?: string;
20
+ /** The `data:` lines joined by newlines, parsed as JSON when they are JSON (`[DONE]` stays a string). */
21
+ data: any;
22
+ id?: string;
23
+ }
24
+ /** Events of a `text/event-stream` body, as the HTML spec's parser dispatches them. */
25
+ export declare function parseEvents(text: string): ServerEvent[];
14
26
  type QueryValue = string | number | boolean;
15
27
  export interface RequestOptions {
16
28
  headers?: Record<string, string>;
@@ -81,7 +93,7 @@ export declare class HttpClient {
81
93
  */
82
94
  concurrently(n: number, send: (i: number) => Promise<HttpResponse>): Promise<HttpResponse[]>;
83
95
  /**
84
- * Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON.
96
+ * Strings, URLSearchParams, FormData, Blob, ArrayBuffer and typed arrays are sent as-is; anything else is sent as JSON.
85
97
  * With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
86
98
  * (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
87
99
  */
@@ -98,6 +110,22 @@ export declare class HttpClient {
98
110
  /** Start a new scenario: forget cookies and history. */
99
111
  reset(): void;
100
112
  }
113
+ /** A `Set-Cookie` header's cookie and attributes, as `toSetCookie()` and YAML `expect.cookies` compare them. */
114
+ export interface SetCookie {
115
+ name: string;
116
+ value: string;
117
+ httpOnly: boolean;
118
+ secure: boolean;
119
+ /** As sent, e.g. `Lax`; undefined when the attribute is missing. */
120
+ sameSite?: string;
121
+ path?: string;
122
+ domain?: string;
123
+ maxAge?: number;
124
+ expires?: string;
125
+ partitioned: boolean;
126
+ }
127
+ /** The cookies a response sets, by name (the last one wins when a name repeats). */
128
+ export declare function setCookies(res: Pick<HttpResponse, "headers">): Record<string, SetCookie>;
101
129
  /** One line per request, for failure output. */
102
130
  export declare function formatHistory(history: readonly HttpResponse[]): string;
103
131
  export {};
package/dist/http.js CHANGED
@@ -2,6 +2,41 @@ import { encodeForm, formRequest } from "./form.js";
2
2
  import { lookupHost } from "./intercept.js";
3
3
  import { timeline } from "./timeline.js";
4
4
  import { signWebhook, webhookBody } from "./webhook.js";
5
+ /** Events of a `text/event-stream` body, as the HTML spec's parser dispatches them. */
6
+ export function parseEvents(text) {
7
+ const out = [];
8
+ let data = [];
9
+ let event;
10
+ let id;
11
+ for (const line of `${text}\n`.split(/\r\n|\r|\n/)) {
12
+ if (line === "") {
13
+ if (data.length) {
14
+ const raw = data.join("\n");
15
+ let parsed = raw;
16
+ try {
17
+ parsed = JSON.parse(raw);
18
+ }
19
+ catch { }
20
+ out.push({ ...(event ? { event } : {}), data: parsed, ...(id !== undefined ? { id } : {}) });
21
+ }
22
+ data = [];
23
+ event = undefined;
24
+ continue;
25
+ }
26
+ if (line.startsWith(":"))
27
+ continue;
28
+ const colon = line.indexOf(":");
29
+ const field = colon < 0 ? line : line.slice(0, colon);
30
+ const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
31
+ if (field === "data")
32
+ data.push(value);
33
+ else if (field === "event")
34
+ event = value;
35
+ else if (field === "id" && !value.includes("\0"))
36
+ id = value;
37
+ }
38
+ return out;
39
+ }
5
40
  /** Per-scenario state shared by a client and every client derived from it with `with()`. */
6
41
  class Session {
7
42
  /** Stub base URLs of intercepted hosts, which redirects may lead to (an OAuth provider's login page). */
@@ -90,7 +125,7 @@ export class HttpClient {
90
125
  webhook(path, payload, opts) {
91
126
  const { body, type } = webhookBody(payload, opts.provider);
92
127
  const url = opts.url ?? new URL(path, this.baseUrl).href;
93
- return this.request("POST", path, body, { headers: { "content-type": type, ...signWebhook(body, { ...opts, url }), ...opts.headers } });
128
+ return this.request("POST", path, body, { headers: mergeHeaders({ "content-type": type, ...signWebhook(body, { ...opts, url }) }, opts.headers) });
94
129
  }
95
130
  /**
96
131
  * Send a GraphQL operation to the app: `http.graphql("query { poll(id: 1) { title } }")`,
@@ -117,7 +152,7 @@ export class HttpClient {
117
152
  return all;
118
153
  }
119
154
  /**
120
- * Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON.
155
+ * Strings, URLSearchParams, FormData, Blob, ArrayBuffer and typed arrays are sent as-is; anything else is sent as JSON.
121
156
  * With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
122
157
  * (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
123
158
  */
@@ -173,7 +208,8 @@ export class HttpClient {
173
208
  body instanceof URLSearchParams ||
174
209
  body instanceof FormData ||
175
210
  body instanceof Blob ||
176
- body instanceof Uint8Array) {
211
+ body instanceof ArrayBuffer ||
212
+ ArrayBuffer.isView(body)) {
177
213
  payload = body;
178
214
  }
179
215
  else {
@@ -196,7 +232,21 @@ export class HttpClient {
196
232
  if (!stub)
197
233
  for (const cookie of res.headers.getSetCookie())
198
234
  this.#storeCookie(cookie, url.pathname);
199
- const text = await res.text();
235
+ let text;
236
+ try {
237
+ text = await res.text();
238
+ }
239
+ catch (e) {
240
+ // The timeout also covers the body: a stream that never ends, or an app that stalls halfway.
241
+ const type = res.headers.get("content-type");
242
+ const failed = { method, url: stub ? url.href : url.pathname + url.search, status: res.status, headers: res.headers, text: "", json: undefined, durationMs: Math.round(performance.now() - started) };
243
+ timeline.set(failed, { start: started, end: performance.now() });
244
+ this.#record(failed);
245
+ const why = e.name === "TimeoutError"
246
+ ? `answered ${res.status}${type ? ` (${type})` : ""} but the body didn't finish within ${opts.timeout}ms (http timeout)${/event-stream/i.test(type ?? "") ? "; the event stream has to end for its events to be checked" : ""}`
247
+ : `the connection broke while reading the ${res.status} response's body: ${e.message}`;
248
+ throw new Error(`slicetest: ${method} ${failed.url}: ${why}`, { cause: e });
249
+ }
200
250
  let json;
201
251
  try {
202
252
  json = text ? JSON.parse(text) : undefined;
@@ -210,6 +260,7 @@ export class HttpClient {
210
260
  text,
211
261
  json,
212
262
  durationMs: Math.round(performance.now() - started),
263
+ ...(/^text\/event-stream\b/i.test(res.headers.get("content-type") ?? "") ? { events: parseEvents(text) } : {}),
213
264
  };
214
265
  timeline.set(out, { start: started, end: performance.now() });
215
266
  this.#record(out);
@@ -292,6 +343,40 @@ export class HttpClient {
292
343
  }
293
344
  }
294
345
  }
346
+ /** The cookies a response sets, by name (the last one wins when a name repeats). */
347
+ export function setCookies(res) {
348
+ const out = {};
349
+ for (const header of res.headers.getSetCookie()) {
350
+ const [pair, ...attrs] = header.split(";");
351
+ const eq = pair.indexOf("=");
352
+ if (eq <= 0)
353
+ continue;
354
+ const cookie = { name: pair.slice(0, eq).trim(), value: pair.slice(eq + 1).trim(), httpOnly: false, secure: false, partitioned: false };
355
+ for (const a of attrs) {
356
+ const i = a.indexOf("=");
357
+ const k = (i < 0 ? a : a.slice(0, i)).trim().toLowerCase();
358
+ const v = i < 0 ? "" : a.slice(i + 1).trim();
359
+ if (k === "httponly")
360
+ cookie.httpOnly = true;
361
+ else if (k === "secure")
362
+ cookie.secure = true;
363
+ else if (k === "partitioned")
364
+ cookie.partitioned = true;
365
+ else if (k === "samesite")
366
+ cookie.sameSite = v;
367
+ else if (k === "path")
368
+ cookie.path = v;
369
+ else if (k === "domain")
370
+ cookie.domain = v;
371
+ else if (k === "max-age")
372
+ cookie.maxAge = Number(v);
373
+ else if (k === "expires")
374
+ cookie.expires = v;
375
+ }
376
+ Object.defineProperty(out, cookie.name, { value: cookie, enumerable: true, writable: true, configurable: true });
377
+ }
378
+ return out;
379
+ }
295
380
  /** RFC 6265 5.1.4: the request path's directory, e.g. "/auth" for "/auth/login". */
296
381
  function defaultCookiePath(requestPath) {
297
382
  const slash = requestPath.lastIndexOf("/");
@@ -319,10 +404,15 @@ function merge(a, b) {
319
404
  return {
320
405
  ...a,
321
406
  ...b,
322
- headers: { ...a.headers, ...b.headers },
407
+ headers: mergeHeaders(a.headers, b.headers),
323
408
  query: { ...a.query, ...b.query },
324
409
  };
325
410
  }
411
+ /** Header names are case-insensitive: a later `authorization` replaces an earlier `Authorization` rather than joining it. */
412
+ function mergeHeaders(a = {}, b = {}) {
413
+ const names = new Set(Object.keys(b).map((k) => k.toLowerCase()));
414
+ return { ...Object.fromEntries(Object.entries(a).filter(([k]) => !names.has(k.toLowerCase()))), ...b };
415
+ }
326
416
  /** One line per request, for failure output. */
327
417
  export function formatHistory(history) {
328
418
  return history
package/dist/init.js CHANGED
@@ -74,6 +74,37 @@ function runnable(dir) {
74
74
  return false;
75
75
  }
76
76
  }
77
+ /** Where plain SQL migrations usually live (golang-migrate, goose, sqlx, Diesel, dbmate). */
78
+ const SQL_MIGRATION_DIRS = ["migrations", "db/migrations", "sql/migrations", "database/migrations"];
79
+ /** `.sql` files, or Diesel's directories with an `up.sql`. */
80
+ function hasSqlMigrations(dir) {
81
+ if (!existsSync(dir))
82
+ return false;
83
+ return readdirSync(dir, { withFileTypes: true }).some((e) => (e.isFile() && e.name.endsWith(".sql")) || (e.isDirectory() && existsSync(path.join(dir, e.name, "up.sql"))));
84
+ }
85
+ /**
86
+ * The Go package to run: `.` when the root is package main, else the one command in `cmd/*`
87
+ * (or the one named server, api, app, web or like the module). Undefined when there is none.
88
+ */
89
+ function goMain(root, goMod) {
90
+ const isMain = (dir) => {
91
+ try {
92
+ return readdirSync(dir).some((f) => f.endsWith(".go") && !f.endsWith("_test.go") && /^\s*package\s+main\b/m.test(readFileSync(path.join(dir, f), "utf8")));
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ };
98
+ if (isMain(root))
99
+ return ".";
100
+ const cmd = path.join(root, "cmd");
101
+ if (!existsSync(cmd))
102
+ return undefined;
103
+ const mains = readdirSync(cmd, { withFileTypes: true }).filter((e) => e.isDirectory() && isMain(path.join(cmd, e.name))).map((e) => e.name).sort();
104
+ const module = /^module\s+(\S+)/m.exec(goMod)?.[1]?.split("/").pop();
105
+ const pick = mains.length === 1 ? mains[0] : ["server", "api", "app", "web", "http", module].find((n) => n && mains.includes(n));
106
+ return pick ? `./cmd/${pick}` : undefined;
107
+ }
77
108
  export async function detect(root) {
78
109
  const notes = [];
79
110
  // The app may live in a subdirectory (backend/, server/, …); everything about the app is read there.
@@ -238,19 +269,33 @@ export async function detect(root) {
238
269
  }
239
270
  }
240
271
  else if (has("go.mod")) {
241
- command = "go run .";
242
- notes.push("app: Go (go.mod). It must listen on $PORT.");
272
+ const main = goMain(appRoot, await read("go.mod"));
273
+ // Built once up front, so the workers' `go run` find everything compiled instead of compiling at once.
274
+ build = "go build ./...";
275
+ command = `go run ${main ?? "."}`;
276
+ readyTimeout = 60_000;
277
+ notes.push(main && main !== "."
278
+ ? `app: Go (go.mod), the main package in ${main.slice(2)}/. It must listen on $PORT.`
279
+ : main
280
+ ? "app: Go (go.mod). It must listen on $PORT."
281
+ : "app: Go (go.mod), but no main package at the root or in cmd/*/: set the package in app.command (`go run ./cmd/server`). It must listen on $PORT.");
243
282
  }
244
283
  else if (has("Cargo.toml")) {
245
- command = "cargo run";
246
- notes.push("app: Rust (Cargo.toml). It must listen on $PORT.");
284
+ const cargo = await read("Cargo.toml");
285
+ // Compiled once before the workers start; `cargo run` then only checks that nothing changed.
286
+ build = "cargo build -q";
287
+ command = "cargo run -q";
288
+ readyTimeout = 120_000;
289
+ notes.push(/^\[workspace\]/m.test(cargo) && !/^\[package\]/m.test(cargo)
290
+ ? "app: Rust workspace (Cargo.toml): add `-p <crate>` to app.command for the server's crate. It must listen on $PORT."
291
+ : "app: Rust (Cargo.toml), built once with `cargo build`. It must listen on $PORT.");
247
292
  }
248
293
  else {
249
294
  notes.push("app: couldn't tell how to start the app. Set app.command.");
250
295
  }
251
296
  // --- migrations ---
252
297
  let migrate;
253
- const migrationsSql = has("migrations") && (await readdir(path.join(appRoot, "migrations"))).some((f) => f.endsWith(".sql"));
298
+ const migrationsDir = SQL_MIGRATION_DIRS.find((d) => hasSqlMigrations(path.join(appRoot, d)));
254
299
  const atlas = await findAtlas(root, appDir);
255
300
  if (atlas) {
256
301
  migrate = { atlas: { dir: `file://${atlas}` } };
@@ -304,9 +349,9 @@ export async function detect(root) {
304
349
  migrate = { command: "npx knex migrate:latest", inputs: ["migrations"] };
305
350
  notes.push("db: Knex migrations");
306
351
  }
307
- else if (migrationsSql) {
308
- migrate = { sql: "migrations" };
309
- notes.push("db: plain SQL files in migrations/, applied in name order");
352
+ else if (migrationsDir) {
353
+ migrate = { sql: migrationsDir };
354
+ notes.push(`db: SQL migrations in ${migrationsDir}/, applied in version order (down migrations left out)`);
310
355
  }
311
356
  else if (has("schema.sql")) {
312
357
  migrate = { sql: "schema.sql" };