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/README.md +83 -17
- package/dist/cli-run.d.ts +13 -0
- package/dist/cli-run.js +15 -0
- package/dist/cli.js +32 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +48 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/form.d.ts +8 -2
- package/dist/form.js +85 -11
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +32 -11
- package/dist/har.js +3 -3
- package/dist/http.d.ts +40 -4
- package/dist/http.js +126 -13
- package/dist/index.d.ts +4 -3
- package/dist/init.js +53 -8
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +21 -3
- package/dist/matchers.js +91 -2
- package/dist/openapi.d.ts +0 -5
- package/dist/openapi.js +13 -6
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/recording.d.ts +7 -0
- package/dist/recording.js +38 -4
- package/dist/runtime.js +13 -0
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -20
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +32 -3
- package/dist/stub.js +148 -8
- package/dist/webhook.d.ts +16 -7
- package/dist/webhook.js +47 -7
- package/dist/yaml-runtime.d.ts +3 -2
- package/dist/yaml-runtime.js +287 -36
- package/dist/yaml.d.ts +51 -4
- package/dist/yaml.js +105 -14
- package/package.json +1 -1
- package/schema/scenario.schema.json +431 -6
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
|
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 (
|
|
308
|
-
migrate = { sql:
|
|
309
|
-
notes.push(
|
|
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
|
+
}
|
package/dist/matchers.d.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { type Where } from "./db.js";
|
|
2
|
-
import type
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
.
|
|
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
|
|
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 =
|
|
119
|
+
step.form = parseForm(reqBody);
|
|
118
120
|
else
|
|
119
121
|
step.body = reqBody;
|
|
120
122
|
}
|
package/dist/recording.d.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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")}`);
|
package/dist/scenario.d.ts
CHANGED
|
@@ -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,
|
|
9
|
-
only: (name: string, body: Body,
|
|
10
|
-
skip: (name: string, body: Body,
|
|
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,
|
|
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 {};
|