slicetest 0.7.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/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" };
package/dist/list.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ export interface ListedScenario {
2
+ file: string;
3
+ line: number;
4
+ name: string;
5
+ tags: string[];
6
+ /** `skip`, `only`, or what `--tag` / `-t` leave out. */
7
+ status: "run" | "skip" | "only" | "filtered";
8
+ /** Rows of `each`: the scenario runs once per row. */
9
+ rows: number;
10
+ steps: number;
11
+ }
12
+ /**
13
+ * The YAML scenarios under `root`, without starting anything: for a quick look at what a run
14
+ * would do, which `--tag` / `-t` select, and for tools (`--json`). Files that don't parse are
15
+ * reported in `errors` with their line, as a run would report them.
16
+ */
17
+ export declare function listScenarios(root: string, opts?: {
18
+ filters?: string[];
19
+ name?: string;
20
+ tags?: string;
21
+ }): Promise<{
22
+ scenarios: ListedScenario[];
23
+ errors: string[];
24
+ }>;
25
+ /** One line per scenario, grouped by file. */
26
+ export declare function formatList({ scenarios, errors }: Awaited<ReturnType<typeof listScenarios>>): string;
package/dist/list.js ADDED
@@ -0,0 +1,71 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { tagsSelected } from "./scenario.js";
4
+ import { parseScenarioFile } from "./yaml.js";
5
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "target", "vendor", ".venv", "venv"]);
6
+ async function scenarioFiles(dir, out = []) {
7
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
8
+ if (entry.isDirectory()) {
9
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith("."))
10
+ await scenarioFiles(path.join(dir, entry.name), out);
11
+ }
12
+ else if (/\.scenario\.ya?ml$/.test(entry.name))
13
+ out.push(path.join(dir, entry.name));
14
+ }
15
+ return out.sort();
16
+ }
17
+ /**
18
+ * The YAML scenarios under `root`, without starting anything: for a quick look at what a run
19
+ * would do, which `--tag` / `-t` select, and for tools (`--json`). Files that don't parse are
20
+ * reported in `errors` with their line, as a run would report them.
21
+ */
22
+ export async function listScenarios(root, opts = {}) {
23
+ const scenarios = [];
24
+ const errors = [];
25
+ const pattern = opts.name ? new RegExp(opts.name) : undefined;
26
+ for (const file of await scenarioFiles(root)) {
27
+ const rel = path.relative(root, file).split(path.sep).join("/");
28
+ if (opts.filters?.length && !opts.filters.some((f) => rel.includes(f)))
29
+ continue;
30
+ try {
31
+ const doc = parseScenarioFile(await readFile(file, "utf8"), rel);
32
+ for (const sc of doc.scenarios) {
33
+ const tags = sc.tags ?? [];
34
+ const selected = tagsSelected(tags, opts.tags) && (!pattern || pattern.test(sc.name));
35
+ scenarios.push({
36
+ file: rel,
37
+ line: sc.line,
38
+ name: sc.name,
39
+ tags,
40
+ status: sc.skip ? "skip" : !selected ? "filtered" : sc.only ? "only" : "run",
41
+ rows: sc.each?.length ?? 1,
42
+ steps: doc.setup.length + sc.steps.length,
43
+ });
44
+ }
45
+ }
46
+ catch (e) {
47
+ errors.push(e.message);
48
+ }
49
+ }
50
+ return { scenarios, errors };
51
+ }
52
+ /** One line per scenario, grouped by file. */
53
+ export function formatList({ scenarios, errors }) {
54
+ const lines = [];
55
+ let file = "";
56
+ for (const s of scenarios) {
57
+ if (s.file !== file) {
58
+ file = s.file;
59
+ lines.push(file);
60
+ }
61
+ const mark = { run: " ", only: "▶ ", skip: "- ", filtered: "· " }[s.status];
62
+ const rows = s.rows > 1 ? ` ×${s.rows}` : "";
63
+ const tags = s.tags.length ? ` [${s.tags.join(", ")}]` : "";
64
+ lines.push(` ${mark}${s.name}${rows}${tags} (line ${s.line}, ${s.steps} steps)`);
65
+ }
66
+ const runs = scenarios.filter((s) => s.status === "run" || s.status === "only").reduce((n, s) => n + s.rows, 0);
67
+ lines.push("", `${scenarios.length} scenario(s) in ${new Set(scenarios.map((s) => s.file)).size} file(s); ${runs} run(s) selected.`);
68
+ if (errors.length)
69
+ lines.push("", "Files with mistakes:", ...errors.map((e) => ` ${e.split("\n")[0]}`));
70
+ return `${lines.join("\n")}\n`;
71
+ }
@@ -1,11 +1,18 @@
1
1
  import { type Where } from "./db.js";
2
- import type { HttpResponse } from "./http.js";
2
+ import { type HttpResponse } from "./http.js";
3
3
  import { type MatchOptions } from "./stub.js";
4
+ /** One expected call for `toHaveReceivedInOrder`: `[stub, method, path, match?]`. */
5
+ export type OrderedCall = [stub: string, method: string, path: string | RegExp, match?: MatchOptions];
4
6
  interface SlicetestMatchers<R = unknown> {
5
7
  /** The stub received at least one call matching `method path` (and `match`, if given). */
6
8
  toHaveReceived(method: string, path: string | RegExp, match?: MatchOptions): R;
7
9
  /** The stub received exactly `n` calls matching `method path`. */
8
10
  toHaveReceivedTimes(n: number, method?: string, path?: string | RegExp, match?: MatchOptions): R;
11
+ /**
12
+ * On the scenario's `stub` accessor: the calls happened in this order, across stubs (other calls
13
+ * may come in between): `expect(stub).toHaveReceivedInOrder([["stripe", "POST", "/v1/charges"], ["mail", "POST", "/send"]])`.
14
+ */
15
+ toHaveReceivedInOrder(calls: OrderedCall[]): R;
9
16
  /** The stub received a GraphQL request for `operation` (with `variables` as a subset, if given). */
10
17
  toHaveReceivedGraphQL(operation: string | RegExp, variables?: unknown): R;
11
18
  /** A GraphQL response without `errors`, whose `data` contains `expected` (if given). The failure message shows the errors. */
@@ -15,8 +22,16 @@ interface SlicetestMatchers<R = unknown> {
15
22
  * relative to the working directory with an optional pointer, `openapi.yaml#/components/schemas/Poll`.
16
23
  */
17
24
  toMatchSchema(schema: object | string): R;
18
- /** The response has this status; the failure message shows the response body. */
19
- toHaveStatus(status: number): R;
25
+ /** The response has this status, a status of this class (`"2xx"`) or one of a list (`[200, 204]`); the failure message shows the response body. */
26
+ toHaveStatus(status: number | `${1 | 2 | 3 | 4 | 5}xx` | (number | `${1 | 2 | 3 | 4 | 5}xx`)[]): R;
27
+ /**
28
+ * The response sets cookie `name`, with these attributes as a subset (`value`, `httpOnly`,
29
+ * `secure`, `sameSite`, `path`, `domain`, `maxAge`, `expires`, `partitioned`):
30
+ * `expect(res).toSetCookie("sid", { httpOnly: true, secure: true, sameSite: "Lax" })`.
31
+ */
32
+ toSetCookie(name: string, attributes?: Record<string, unknown>): R;
33
+ /** The response arrived within `ms` milliseconds (measured from sending the request to reading the whole body). */
34
+ toRespondWithin(ms: number): R;
20
35
  /** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`. */
21
36
  toHaveStatuses(counts: Record<number, number>): R;
22
37
  /** Async: the table has at least one row matching `where` (`count` for an exact number). */
@@ -26,5 +41,8 @@ declare module "vitest" {
26
41
  interface Matchers<R extends void | Promise<void> = void | Promise<void>, T = unknown> extends SlicetestMatchers<R> {
27
42
  }
28
43
  }
44
+ /** A status code, a class (`"2xx"`), or a list of either: `[200, 204]`, `["2xx", 304]`. */
45
+ export type ExpectedStatus = number | string | (number | string)[];
46
+ export declare function statusMatches(expected: ExpectedStatus, actual: number): boolean;
29
47
  export declare function statusCounts(responses: HttpResponse[]): Record<number, number>;
30
48
  export {};
package/dist/matchers.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { expect } from "vitest";
2
2
  import { Db } from "./db.js";
3
+ import { setCookies } from "./http.js";
3
4
  import { describeGraphQL } from "./graphql.js";
4
5
  import { schemaProblems } from "./schema.js";
5
6
  import { Stub, subset } from "./stub.js";
7
+ import { timeline } from "./timeline.js";
6
8
  const MAX_SHOWN = 5;
7
9
  function describeCalls(calls) {
8
10
  if (calls.length === 0)
@@ -21,6 +23,24 @@ function assertStub(received) {
21
23
  if (!(received instanceof Stub))
22
24
  throw new TypeError("slicetest: expected a stub, e.g. expect(stub(\"slack\"))");
23
25
  }
26
+ export function statusMatches(expected, actual) {
27
+ if (Array.isArray(expected)) {
28
+ if (expected.length === 0)
29
+ throw new TypeError("slicetest: toHaveStatus needs at least one status in the list");
30
+ return expected.some((e) => statusMatches(e, actual));
31
+ }
32
+ if (typeof expected === "number")
33
+ return expected === actual;
34
+ const s = String(expected).trim().toLowerCase();
35
+ if (/^\d{3}$/.test(s))
36
+ return Number(s) === actual;
37
+ if (/^[1-5]xx$/.test(s))
38
+ return Math.floor(actual / 100) === Number(s[0]);
39
+ throw new TypeError(`slicetest: a status is a code (201), a class ("2xx") or a list of them, got ${JSON.stringify(expected)}`);
40
+ }
41
+ function describeStatus(expected) {
42
+ return Array.isArray(expected) ? `one of ${expected.join(", ")}` : String(expected);
43
+ }
24
44
  export function statusCounts(responses) {
25
45
  const out = {};
26
46
  for (const r of responses)
@@ -63,6 +83,39 @@ expect.extend({
63
83
  expected: n,
64
84
  };
65
85
  },
86
+ toHaveReceivedInOrder(received, expected) {
87
+ if (typeof received !== "function")
88
+ throw new TypeError("slicetest: toHaveReceivedInOrder takes the scenario's stub accessor: expect(stub).toHaveReceivedInOrder([...])");
89
+ const stubOf = received;
90
+ const startOf = (c) => timeline.get(c)?.start ?? 0;
91
+ let after = -Infinity;
92
+ let failedAt = -1;
93
+ for (const [i, [name, method, path, match]] of expected.entries()) {
94
+ const next = stubOf(name)
95
+ .calls(method, path, match)
96
+ .map(startOf)
97
+ .filter((t) => t > after)
98
+ .sort((a, b) => a - b)[0];
99
+ if (next === undefined) {
100
+ failedAt = i;
101
+ break;
102
+ }
103
+ after = next;
104
+ }
105
+ const names = [...new Set(expected.map(([n]) => n))];
106
+ const all = names
107
+ .flatMap((n) => stubOf(n).calls().map((c) => ({ n, c })))
108
+ .sort((a, b) => startOf(a.c) - startOf(b.c))
109
+ .map(({ n, c }) => ` ${n}: ${c.graphql ? describeGraphQL(c.graphql) : `${c.method} ${c.path}`}`);
110
+ const label = ([n, m, p, match]) => `${n}: ${m} ${p}${match && Object.keys(match).length ? ` with ${JSON.stringify(match, (_, v) => (v instanceof RegExp ? String(v) : v))}` : ""}`;
111
+ return {
112
+ pass: failedAt === -1,
113
+ message: () => failedAt === -1
114
+ ? `expected the stubs not to receive, in this order:\n${expected.map((e) => ` ${label(e)}`).join("\n")}`
115
+ : `expected the stubs to receive, in this order:\n${expected.map((e, i) => ` ${i === failedAt ? "✗" : i < failedAt ? "✓" : " "} ${label(e)}`).join("\n")}\n` +
116
+ `${failedAt === 0 ? "the first call never came" : `no matching call came after #${failedAt}`}. Calls received, in order:\n${all.join("\n") || " (no calls)"}`,
117
+ };
118
+ },
66
119
  toHaveReceivedGraphQL(received, operation, variables) {
67
120
  assertStub(received);
68
121
  const pass = received.calls("*", undefined, { graphql: { operation, variables } }).length > 0;
@@ -106,18 +159,54 @@ expect.extend({
106
159
  };
107
160
  },
108
161
  toHaveStatus(received, status) {
109
- const pass = received?.status === status;
162
+ if (!received || typeof received !== "object" || typeof received.status !== "number") {
163
+ throw new TypeError(`slicetest: toHaveStatus expects a response from http, got ${this.utils.stringify(received)}`);
164
+ }
165
+ const pass = statusMatches(status, received.status);
110
166
  return {
111
167
  pass,
112
168
  message: () => {
113
169
  const body = received.text.length > 1000 ? `${received.text.slice(0, 1000)}…` : received.text;
114
- return (`expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond ${status}, got ${received.status}\n` +
170
+ return (`expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond ${describeStatus(status)}, got ${received.status}\n` +
115
171
  `Response body:\n ${body || "(empty)"}`);
116
172
  },
117
173
  actual: received?.status,
118
174
  expected: status,
119
175
  };
120
176
  },
177
+ toSetCookie(received, name, attributes = {}) {
178
+ if (!received || typeof received !== "object" || !(received.headers instanceof Headers)) {
179
+ throw new TypeError(`slicetest: toSetCookie expects a response from http, got ${this.utils.stringify(received)}`);
180
+ }
181
+ const cookies = setCookies(received);
182
+ const cookie = Object.hasOwn(cookies, name) ? cookies[name] : undefined;
183
+ // SameSite values are case-insensitive: "Lax" and "lax" are the same attribute. A matcher sees the value as sent.
184
+ const plain = typeof attributes.sameSite === "string";
185
+ const want = plain ? { ...attributes, sameSite: attributes.sameSite.toLowerCase() } : attributes;
186
+ const pass = !!cookie && subset(want, plain ? { ...cookie, sameSite: cookie.sameSite?.toLowerCase() } : cookie);
187
+ const describe = (c) => `${c.name}=${c.value.length > 40 ? `${c.value.slice(0, 40)}…` : c.value} ${JSON.stringify(Object.fromEntries(Object.entries(c).filter(([k, v]) => k !== "name" && k !== "value" && v !== undefined && v !== false)))}`;
188
+ return {
189
+ pass,
190
+ message: () => {
191
+ const head = `expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to set cookie ${name}${Object.keys(attributes).length ? ` with ${this.utils.stringify(attributes, 10, { min: true })}` : ""}`;
192
+ const all = Object.values(cookies);
193
+ return `${head}\n${all.length ? `Cookies it set:\n${all.map((c) => ` ${describe(c)}`).join("\n")}` : "It set no cookies."}`;
194
+ },
195
+ };
196
+ },
197
+ toRespondWithin(received, ms) {
198
+ if (!received || typeof received !== "object" || typeof received.durationMs !== "number") {
199
+ throw new TypeError(`slicetest: toRespondWithin expects a response from http, got ${this.utils.stringify(received)}`);
200
+ }
201
+ if (!(typeof ms === "number" && ms > 0))
202
+ throw new TypeError(`slicetest: toRespondWithin needs a positive number of milliseconds, got ${this.utils.stringify(ms)}`);
203
+ return {
204
+ pass: received.durationMs <= ms,
205
+ message: () => `expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond within ${ms}ms, it took ${received.durationMs}ms`,
206
+ actual: received.durationMs,
207
+ expected: ms,
208
+ };
209
+ },
121
210
  toHaveStatuses(received, counts) {
122
211
  if (!Array.isArray(received))
123
212
  throw new TypeError("slicetest: toHaveStatuses expects an array of responses, e.g. from http.concurrently()");
package/dist/openapi.d.ts CHANGED
@@ -31,11 +31,6 @@ export interface Message {
31
31
  body: unknown;
32
32
  query?: URLSearchParams;
33
33
  }
34
- /**
35
- * An OpenAPI 3.0 / 3.1 document, used to check that real traffic matches it:
36
- * the app's responses against the app's own spec, and the app's calls to a
37
- * stubbed service (and the stub's canned replies) against that service's spec.
38
- */
39
34
  export declare class OpenApiSpec {
40
35
  #private;
41
36
  readonly file: string;
package/dist/openapi.js CHANGED
@@ -5,11 +5,17 @@ import addFormatsModule from "ajv-formats";
5
5
  import { parse } from "yaml";
6
6
  const addFormats = (addFormatsModule.default ?? addFormatsModule);
7
7
  const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
8
- /**
9
- * An OpenAPI 3.0 / 3.1 document, used to check that real traffic matches it:
10
- * the app's responses against the app's own spec, and the app's calls to a
11
- * stubbed service (and the stub's canned replies) against that service's spec.
12
- */
8
+ /** A server's URL with its `{variables}` filled in: every `enum` value, else the default. */
9
+ function serverUrls(server) {
10
+ let urls = [server.url ?? "/"];
11
+ for (const [name, v] of Object.entries(server.variables ?? {})) {
12
+ const values = [...new Set([...(v.default === undefined ? [] : [v.default]), ...(v.enum ?? [])].map(String))];
13
+ if (values.length === 0 || !urls[0].includes(`{${name}}`))
14
+ continue;
15
+ urls = urls.flatMap((u) => values.map((value) => u.replaceAll(`{${name}}`, value))).slice(0, 50);
16
+ }
17
+ return urls;
18
+ }
13
19
  export class OpenApiSpec {
14
20
  file;
15
21
  doc;
@@ -45,7 +51,8 @@ export class OpenApiSpec {
45
51
  // Paths in the spec are relative to the server URL, e.g. /v1 for https://api.example.com/v1.
46
52
  this.#basePaths = [
47
53
  ...new Set((doc.servers ?? [])
48
- .map((s) => new URL(s.url ?? "/", "http://x").pathname.replace(/\/$/, ""))
54
+ .flatMap((s) => serverUrls(s))
55
+ .map((url) => new URL(url, "http://x").pathname.replace(/\/$/, ""))
49
56
  .filter(Boolean)),
50
57
  ];
51
58
  }
package/dist/record.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Changes } from "./db.js";
2
2
  import type { ScenarioContext } from "./runtime.js";
3
- import type { RecordedCall } from "./stub.js";
3
+ import { type RecordedCall } from "./stub.js";
4
4
  /**
5
5
  * `npx slicetest record`: use the app for real (a browser, curl, a mobile
6
6
  * client) through a proxy, and get the session back as a YAML scenario: the
package/dist/record.js CHANGED
@@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises";
3
3
  import http from "node:http";
4
4
  import path from "node:path";
5
5
  import { stringify } from "yaml";
6
+ import { parseForm } from "./stub.js";
6
7
  import { mask } from "./trace.js";
7
8
  export const SESSION_ENV = "SLICETEST_RECORD_SESSION";
8
9
  /** Requests a browser makes on its own, not part of what the scenario is about. */
@@ -113,8 +114,9 @@ export function buildScenario(exchanges, calls, changes, { name = "recorded sess
113
114
  if (x.body) {
114
115
  if (/json/i.test(x.contentType ?? ""))
115
116
  step.json = parse(reqBody, "application/json");
117
+ // Nested and repeated fields survive: `form:` is encoded back the same way on replay.
116
118
  else if (/x-www-form-urlencoded/i.test(x.contentType ?? ""))
117
- step.form = Object.fromEntries(new URLSearchParams(reqBody));
119
+ step.form = parseForm(reqBody);
118
120
  else
119
121
  step.body = reqBody;
120
122
  }
@@ -38,6 +38,13 @@ export declare class Recorder {
38
38
  /** Recordings made by this worker, to be merged into the file when the run ends. */
39
39
  added(): Recording[];
40
40
  }
41
+ export declare const REDACTED = "[redacted]";
42
+ /**
43
+ * A request with credential values replaced by `[redacted]`, so recordings can be committed and
44
+ * replay whatever key the test environment uses. Applied to recordings and to calls alike before
45
+ * they are compared.
46
+ */
47
+ export declare function redactRequest(r: Recording["request"]): Recording["request"];
41
48
  export declare function readRecordings(file: string): Promise<Recording[]>;
42
49
  /** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
43
50
  export declare function mergeRecordings(file: string, upstream: string, added: Recording[], source?: string): Promise<void>;
package/dist/recording.js CHANGED
@@ -90,14 +90,48 @@ export class Recorder {
90
90
  }
91
91
  function requestOf(call) {
92
92
  const query = Object.fromEntries([...call.query.entries()].sort(([a], [b]) => a.localeCompare(b)));
93
- return {
93
+ return redactRequest({
94
94
  method: call.method,
95
95
  path: call.path,
96
96
  ...(Object.keys(query).length ? { query } : {}),
97
97
  ...(call.json !== undefined ? { json: call.json } : call.body ? { body: call.body } : {}),
98
- };
98
+ });
99
99
  }
100
- function keyOf(r) {
100
+ /** Credentials sent in a query, a form or JSON body: `api_key`, `key`, `access_token`, `client_secret`, `password`, `appid`, signatures. */
101
+ const SECRET = /^(api[-_]?key|apikey|key|(access|refresh|id|auth)[-_]?token|token|client[-_]?secret|app[-_]?secret|secret|password|passwd|appid|signature|sig|x-amz-(signature|credential|security-token))$/i;
102
+ /** In bodies, a bare `key` is usually data (a KV store's key, a record's key) that tells requests apart, not a credential. */
103
+ const bodySecret = (k) => SECRET.test(k) && k.toLowerCase() !== "key";
104
+ export const REDACTED = "[redacted]";
105
+ const FORM_BODY = /^[^\s=&]+=[^\s&]*(&[^\s=&]+=[^\s&]*)*$/;
106
+ /**
107
+ * A request with credential values replaced by `[redacted]`, so recordings can be committed and
108
+ * replay whatever key the test environment uses. Applied to recordings and to calls alike before
109
+ * they are compared.
110
+ */
111
+ export function redactRequest(r) {
112
+ const out = { ...r };
113
+ if (r.query)
114
+ out.query = Object.fromEntries(Object.entries(r.query).map(([k, v]) => [k, SECRET.test(k) ? REDACTED : v]));
115
+ if (r.json !== undefined)
116
+ out.json = redactJson(r.json);
117
+ if (r.body && FORM_BODY.test(r.body)) {
118
+ const form = new URLSearchParams(r.body);
119
+ if ([...form.keys()].some(bodySecret)) {
120
+ out.body = [...form].map(([k, v]) => `${encodeURIComponent(k)}=${bodySecret(k) ? REDACTED : encodeURIComponent(v)}`).join("&");
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+ function redactJson(v) {
126
+ if (Array.isArray(v))
127
+ return v.map(redactJson);
128
+ if (v && typeof v === "object")
129
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, typeof x === "string" && bodySecret(k) ? REDACTED : redactJson(x)]));
130
+ return v;
131
+ }
132
+ function keyOf(request) {
133
+ // Recordings made before redaction still match: both sides are compared redacted.
134
+ const r = redactRequest(request);
101
135
  return JSON.stringify([r.method.toUpperCase(), r.path, sortKeys(r.query ?? {}), sortKeys(r.json ?? null), r.body ?? ""]);
102
136
  }
103
137
  function sortKeys(v) {
@@ -143,6 +177,6 @@ export async function mergeRecordings(file, upstream, added, source = `Recorded
143
177
  if (!entries.some((x) => isDeepStrictEqual(x, e)))
144
178
  entries.push(e);
145
179
  await mkdir(path.dirname(file), { recursive: true });
146
- const header = `# ${source}. Review before committing: request bodies are stored as sent.\n`;
180
+ const header = `# ${source}. Review before committing: credentials in queries and bodies (api_key, access_token, client_secret, password, …) are replaced by ${REDACTED}, but other values are stored as sent.\n`;
147
181
  await writeFile(file, header + YAML.stringify(entries, { lineWidth: 0 }));
148
182
  }
package/dist/runtime.js CHANGED
@@ -124,6 +124,7 @@ export class Runtime {
124
124
  ? await Db.connect(await engine.driver(url), url, {
125
125
  schemas: opts.db.schemas,
126
126
  keep: opts.db.keep,
127
+ ignoreChanges: opts.db.ignoreChanges,
127
128
  seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
128
129
  })
129
130
  : noDatabase();
@@ -301,6 +302,9 @@ export class Runtime {
301
302
  this.#assertAlive();
302
303
  if (this.interceptor?.blocked.size)
303
304
  throw new Error(blockedHint([...this.interceptor.blocked]));
305
+ const thrown = this.#handlerErrors();
306
+ if (thrown.length > 0)
307
+ throw new Error(`slicetest: a stub's reply threw, so the app got an error response instead of the reply:\n${thrown.join("\n")}`);
304
308
  const unmatched = this.#unmatched();
305
309
  if (unmatched.length > 0) {
306
310
  throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
@@ -346,6 +350,12 @@ export class Runtime {
346
350
  }
347
351
  return out;
348
352
  }
353
+ #handlerErrors() {
354
+ return [...this.stubs.values()].flatMap((s) => s.handlerErrors().map(({ call, error }) => {
355
+ const where = error?.stack?.split("\n").find((l) => /^\s+at /.test(l) && !l.includes("node_modules") && !l.includes("node:"))?.trim();
356
+ return ` ${s.name}: ${call.method} ${call.path}: ${error instanceof Error ? error.message : String(error)}${where ? `\n ${where}` : ""}`;
357
+ }));
358
+ }
349
359
  #unmatched() {
350
360
  return [...this.stubs.values()].flatMap((s) => {
351
361
  const calls = s.unmatched();
@@ -400,6 +410,9 @@ export class Runtime {
400
410
  if (exit)
401
411
  sections.push(`the ${p.label} exited (code ${exit.code}, signal ${exit.signal}); it will be restarted for the next scenario`);
402
412
  }
413
+ const thrown = this.#handlerErrors();
414
+ if (thrown.length > 0)
415
+ sections.push(`stub replies that threw:\n${thrown.join("\n")}`);
403
416
  const unmatched = this.#unmatched();
404
417
  if (unmatched.length > 0)
405
418
  sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
@@ -1,15 +1,25 @@
1
1
  import type { Runtime, ScenarioContext } from "./runtime.js";
2
2
  export declare function setRuntime(runtime: Runtime | undefined): void;
3
3
  type Body = (ctx: ScenarioContext) => Promise<void> | void;
4
+ export interface ScenarioOptions {
5
+ timeout?: number;
6
+ /** Labels to select scenarios by: `npx slicetest --tag smoke`, or `SLICETEST_TAGS=smoke,!slow` with Vitest. */
7
+ tags?: string[];
8
+ }
9
+ /**
10
+ * Whether a scenario with `tags` runs under `filter` (`SLICETEST_TAGS`): comma- or space-separated tags,
11
+ * any of which it must have, and `!tag`s it must not have. No filter runs everything.
12
+ */
13
+ export declare function tagsSelected(tags?: readonly string[], filter?: string | undefined): boolean;
4
14
  /**
5
15
  * A test that runs against the real app. The database is reset to the
6
16
  * migrated schema (plus seed) and stubs are cleared before each scenario.
7
17
  */
8
- export declare const scenario: ((name: string, body: Body, timeout?: number) => void) & {
9
- only: (name: string, body: Body, timeout?: number) => void;
10
- skip: (name: string, body: Body, timeout?: number) => void;
18
+ export declare const scenario: ((name: string, body: Body, options?: number | ScenarioOptions) => void) & {
19
+ only: (name: string, body: Body, options?: number | ScenarioOptions) => void;
20
+ skip: (name: string, body: Body, options?: number | ScenarioOptions) => void;
11
21
  todo: (name: string) => void;
12
22
  /** Same scenario for each row: `scenario.each(rows)("name %s", async (row, ctx) => ...)`. */
13
- each<T>(rows: readonly T[]): (name: string, body: (row: T, ctx: ScenarioContext) => Promise<void> | void, timeout?: number) => void;
23
+ each<T>(rows: readonly T[]): (name: string, body: (row: T, ctx: ScenarioContext) => Promise<void> | void, options?: number | ScenarioOptions) => void;
14
24
  };
15
25
  export {};