slicetest 0.7.0 → 0.8.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 +71 -13
- package/dist/cli.js +20 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +3 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/form.d.ts +7 -2
- package/dist/form.js +72 -7
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +29 -5
- package/dist/http.d.ts +11 -3
- package/dist/http.js +32 -9
- package/dist/index.d.ts +4 -3
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +7 -0
- package/dist/matchers.js +34 -0
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/runtime.js +1 -0
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -20
- package/dist/stub.d.ts +22 -0
- package/dist/stub.js +105 -2
- 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 +244 -34
- package/dist/yaml.d.ts +43 -2
- package/dist/yaml.js +91 -12
- package/package.json +1 -1
- package/schema/scenario.schema.json +361 -2
package/dist/global-setup.js
CHANGED
|
@@ -38,7 +38,7 @@ export default async function setup(project) {
|
|
|
38
38
|
}
|
|
39
39
|
finally {
|
|
40
40
|
if (coverageDir)
|
|
41
|
-
await reportCoverage(opts, coverageDir);
|
|
41
|
+
await reportCoverage(opts, coverageDir, await partialRun(project));
|
|
42
42
|
if (usageDir)
|
|
43
43
|
await reportUsage(opts, usageDir);
|
|
44
44
|
if (recordDir)
|
|
@@ -102,7 +102,30 @@ async function startDatabase(opts, builds) {
|
|
|
102
102
|
},
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Why this run covers only part of the suite (a file or name filter, tags, a shard), or undefined for a full run.
|
|
107
|
+
* Coverage then says little about the suite, so `minCoverage` isn't enforced and the cache for `gen --uncovered` is kept.
|
|
108
|
+
*/
|
|
109
|
+
export async function partialRun(project) {
|
|
110
|
+
try {
|
|
111
|
+
const config = project.vitest.config;
|
|
112
|
+
if (config.testNamePattern)
|
|
113
|
+
return `only scenarios matching ${config.testNamePattern}`;
|
|
114
|
+
if (process.env.SLICETEST_TAGS)
|
|
115
|
+
return `only tags ${process.env.SLICETEST_TAGS}`;
|
|
116
|
+
if (config.shard)
|
|
117
|
+
return "one shard";
|
|
118
|
+
const all = (await project.globTestFiles()).testFiles.length;
|
|
119
|
+
const ran = project.vitest.state.getFiles().filter((f) => f.projectName === project.name).length;
|
|
120
|
+
if (ran > 0 && ran < all)
|
|
121
|
+
return `${ran} of ${all} files`;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// An API this Vitest version lacks: treat the run as complete, as before.
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
async function reportCoverage(opts, dir, partial) {
|
|
106
129
|
try {
|
|
107
130
|
const hits = new Set();
|
|
108
131
|
for (const file of await readdir(dir)) {
|
|
@@ -117,11 +140,12 @@ async function reportCoverage(opts, dir) {
|
|
|
117
140
|
const report = formatCoverage(spec, hits);
|
|
118
141
|
// For `slicetest gen --uncovered`.
|
|
119
142
|
const cache = coverageCacheFile(opts.root);
|
|
120
|
-
|
|
121
|
-
|
|
143
|
+
if (!partial)
|
|
144
|
+
await mkdir(path.dirname(cache), { recursive: true }).then(() => writeFile(cache, JSON.stringify([...hits]))).catch(() => { });
|
|
145
|
+
console.log(`\n${report.text}${partial ? `\n(partial run: ${partial}; openapi.minCoverage is checked on full runs)` : ""}\n`);
|
|
122
146
|
await appendSummary(report.markdown);
|
|
123
147
|
const min = opts.openapi.minCoverage;
|
|
124
|
-
if (min !== undefined && report.percent < min) {
|
|
148
|
+
if (min !== undefined && report.percent < min && !partial) {
|
|
125
149
|
// Not thrown: Vitest reports teardown errors as a crash. The failing exit code is what CI needs.
|
|
126
150
|
const message = `OpenAPI coverage ${report.percent}% is below openapi.minCoverage (${min}%)`;
|
|
127
151
|
console.error(`slicetest: ${message}\n`);
|
package/dist/http.d.ts
CHANGED
|
@@ -11,11 +11,15 @@ export interface HttpResponse {
|
|
|
11
11
|
/** Wall-clock time of the request in milliseconds. */
|
|
12
12
|
durationMs: number;
|
|
13
13
|
}
|
|
14
|
+
type QueryValue = string | number | boolean;
|
|
14
15
|
export interface RequestOptions {
|
|
15
16
|
headers?: Record<string, string>;
|
|
16
|
-
|
|
17
|
+
/** A list repeats the parameter: `{ tag: ["a", "b"] }` is `?tag=a&tag=b`. */
|
|
18
|
+
query?: Record<string, QueryValue | QueryValue[] | undefined>;
|
|
17
19
|
/** Follow redirects instead of returning the 3xx response. Default false. */
|
|
18
20
|
follow?: boolean;
|
|
21
|
+
/** Fail the request when the app hasn't answered within this many ms, naming it, instead of the whole test timing out. */
|
|
22
|
+
timeout?: number;
|
|
19
23
|
}
|
|
20
24
|
/** Per-scenario state shared by a client and every client derived from it with `with()`. */
|
|
21
25
|
declare class Session {
|
|
@@ -35,8 +39,12 @@ export declare class HttpClient {
|
|
|
35
39
|
constructor(baseUrl: string, defaults?: RequestOptions, session?: Session);
|
|
36
40
|
/** A client that adds `opts` (e.g. an auth header) to every request, sharing this one's cookies. */
|
|
37
41
|
with(opts: RequestOptions): HttpClient;
|
|
38
|
-
/**
|
|
39
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Wrap fields to be sent as `application/x-www-form-urlencoded`. A list repeats the key (`to=a&to=b`,
|
|
44
|
+
* as an HTML form sends it), and nested objects use bracket keys (`metadata[order]=7`,
|
|
45
|
+
* `items[0][price]=p_1`), as Rails, PHP and Stripe read them. `null` / `undefined` are left out.
|
|
46
|
+
*/
|
|
47
|
+
form(fields: Record<string, unknown>): URLSearchParams;
|
|
40
48
|
get(path: string, opts?: RequestOptions): Promise<HttpResponse>;
|
|
41
49
|
head(path: string, opts?: RequestOptions): Promise<HttpResponse>;
|
|
42
50
|
delete(path: string, opts?: RequestOptions): Promise<HttpResponse>;
|
package/dist/http.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formRequest } from "./form.js";
|
|
1
|
+
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";
|
|
@@ -34,9 +34,13 @@ export class HttpClient {
|
|
|
34
34
|
with(opts) {
|
|
35
35
|
return new HttpClient(this.baseUrl, merge(this.defaults, opts), this.#session);
|
|
36
36
|
}
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Wrap fields to be sent as `application/x-www-form-urlencoded`. A list repeats the key (`to=a&to=b`,
|
|
39
|
+
* as an HTML form sends it), and nested objects use bracket keys (`metadata[order]=7`,
|
|
40
|
+
* `items[0][price]=p_1`), as Rails, PHP and Stripe read them. `null` / `undefined` are left out.
|
|
41
|
+
*/
|
|
38
42
|
form(fields) {
|
|
39
|
-
return
|
|
43
|
+
return encodeForm(fields);
|
|
40
44
|
}
|
|
41
45
|
get(path, opts) {
|
|
42
46
|
return this.request("GET", path, undefined, opts);
|
|
@@ -84,8 +88,9 @@ export class HttpClient {
|
|
|
84
88
|
* `{ invalidSignature: true }` and `{ stale: true }` make deliveries the app must refuse.
|
|
85
89
|
*/
|
|
86
90
|
webhook(path, payload, opts) {
|
|
87
|
-
const { body, type } = webhookBody(payload);
|
|
88
|
-
|
|
91
|
+
const { body, type } = webhookBody(payload, opts.provider);
|
|
92
|
+
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 } });
|
|
89
94
|
}
|
|
90
95
|
/**
|
|
91
96
|
* Send a GraphQL operation to the app: `http.graphql("query { poll(id: 1) { title } }")`,
|
|
@@ -122,9 +127,17 @@ export class HttpClient {
|
|
|
122
127
|
if (url.origin !== new URL(this.baseUrl).origin) {
|
|
123
128
|
throw new Error(`slicetest: http only talks to the app under test; "${path}" resolves to ${url.origin}`);
|
|
124
129
|
}
|
|
125
|
-
for (const [k, v] of Object.entries(opts.query ?? {}))
|
|
126
|
-
if (v
|
|
130
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
131
|
+
if (v === undefined)
|
|
132
|
+
continue;
|
|
133
|
+
if (Array.isArray(v)) {
|
|
134
|
+
url.searchParams.delete(k);
|
|
135
|
+
for (const item of v)
|
|
136
|
+
url.searchParams.append(k, String(item));
|
|
137
|
+
}
|
|
138
|
+
else
|
|
127
139
|
url.searchParams.set(k, String(v));
|
|
140
|
+
}
|
|
128
141
|
let res = await this.#send(method, url, body, opts);
|
|
129
142
|
for (let hops = 0; opts.follow && REDIRECTS.has(res.status) && hops < 20; hops++) {
|
|
130
143
|
const location = res.headers.get("location");
|
|
@@ -171,14 +184,14 @@ export class HttpClient {
|
|
|
171
184
|
const started = performance.now();
|
|
172
185
|
let res;
|
|
173
186
|
try {
|
|
174
|
-
res = await fetch(target, { method, headers, body: payload, redirect: "manual" });
|
|
187
|
+
res = await fetch(target, { method, headers, body: payload, redirect: "manual", signal: opts.timeout ? AbortSignal.timeout(opts.timeout) : undefined });
|
|
175
188
|
}
|
|
176
189
|
catch (e) {
|
|
177
190
|
const cause = e.cause?.code;
|
|
178
191
|
const failed = { method, url: url.pathname + url.search, status: 0, headers: new Headers(), text: `${e}${cause ? ` (${cause})` : ""}`, json: undefined, durationMs: Math.round(performance.now() - started) };
|
|
179
192
|
timeline.set(failed, { start: started, end: performance.now() });
|
|
180
193
|
this.#record(failed);
|
|
181
|
-
throw e;
|
|
194
|
+
throw new Error(`slicetest: ${method} ${failed.url}: ${requestFailure(e, cause, opts.timeout)}`, { cause: e });
|
|
182
195
|
}
|
|
183
196
|
if (!stub)
|
|
184
197
|
for (const cookie of res.headers.getSetCookie())
|
|
@@ -292,6 +305,16 @@ function pathMatches(requestPath, cookiePath) {
|
|
|
292
305
|
return false;
|
|
293
306
|
return cookiePath.endsWith("/") || requestPath[cookiePath.length] === "/";
|
|
294
307
|
}
|
|
308
|
+
/** Why a request got no response, in terms of what to look at. */
|
|
309
|
+
function requestFailure(e, code, timeout) {
|
|
310
|
+
if (e.name === "TimeoutError")
|
|
311
|
+
return `no response within ${timeout}ms (http timeout); the app may be waiting on a stub with a delay, a lock or a slow query`;
|
|
312
|
+
if (code === "ECONNREFUSED")
|
|
313
|
+
return "connection refused: the app isn't listening (it may have crashed; its output is below)";
|
|
314
|
+
if (code === "ECONNRESET" || code === "UND_ERR_SOCKET")
|
|
315
|
+
return "the app closed the connection without answering (did it crash or exit?)";
|
|
316
|
+
return `${e.message}${code ? ` (${code})` : ""}`;
|
|
317
|
+
}
|
|
295
318
|
function merge(a, b) {
|
|
296
319
|
return {
|
|
297
320
|
...a,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export { scenario } from "./scenario.js";
|
|
2
|
+
export type { ScenarioOptions } from "./scenario.js";
|
|
2
3
|
export { mask } from "./trace.js";
|
|
3
4
|
export type { Trace, MaskOptions } from "./trace.js";
|
|
4
5
|
export type { ScenarioContext } from "./runtime.js";
|
|
5
|
-
export type { Db, Row, Where, RowsOptions, Changes, TableChanges } from "./db.js";
|
|
6
|
-
export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder, GraphQLRouteBuilder, ChaosOptions, ServerSentEvent } from "./stub.js";
|
|
6
|
+
export type { Db, Row, Where, RowsOptions, Changes, TableChanges, ChangesOptions } from "./db.js";
|
|
7
|
+
export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder, GraphQLRouteBuilder, ChaosOptions, ServerSentEvent, UploadedFile } from "./stub.js";
|
|
7
8
|
export type { GraphQLCall } from "./graphql.js";
|
|
8
9
|
export { sse } from "./stub.js";
|
|
9
10
|
export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
|
|
@@ -15,4 +16,4 @@ export type { Issuer, TokenOptions, AuthOptions } from "./auth.js";
|
|
|
15
16
|
export { normalizeSql, type Query, type QueryList } from "./query-log.js";
|
|
16
17
|
export { signWebhook, type WebhookOptions, type WebhookProvider, type HmacScheme } from "./webhook.js";
|
|
17
18
|
export type { SlicetestOptions, ContainerOptions } from "./config.js";
|
|
18
|
-
export type {} from "./matchers.js";
|
|
19
|
+
export type { OrderedCall } from "./matchers.js";
|
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
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. */
|
package/dist/matchers.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Db } from "./db.js";
|
|
|
3
3
|
import { describeGraphQL } from "./graphql.js";
|
|
4
4
|
import { schemaProblems } from "./schema.js";
|
|
5
5
|
import { Stub, subset } from "./stub.js";
|
|
6
|
+
import { timeline } from "./timeline.js";
|
|
6
7
|
const MAX_SHOWN = 5;
|
|
7
8
|
function describeCalls(calls) {
|
|
8
9
|
if (calls.length === 0)
|
|
@@ -63,6 +64,39 @@ expect.extend({
|
|
|
63
64
|
expected: n,
|
|
64
65
|
};
|
|
65
66
|
},
|
|
67
|
+
toHaveReceivedInOrder(received, expected) {
|
|
68
|
+
if (typeof received !== "function")
|
|
69
|
+
throw new TypeError("slicetest: toHaveReceivedInOrder takes the scenario's stub accessor: expect(stub).toHaveReceivedInOrder([...])");
|
|
70
|
+
const stubOf = received;
|
|
71
|
+
const startOf = (c) => timeline.get(c)?.start ?? 0;
|
|
72
|
+
let after = -Infinity;
|
|
73
|
+
let failedAt = -1;
|
|
74
|
+
for (const [i, [name, method, path, match]] of expected.entries()) {
|
|
75
|
+
const next = stubOf(name)
|
|
76
|
+
.calls(method, path, match)
|
|
77
|
+
.map(startOf)
|
|
78
|
+
.filter((t) => t > after)
|
|
79
|
+
.sort((a, b) => a - b)[0];
|
|
80
|
+
if (next === undefined) {
|
|
81
|
+
failedAt = i;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
after = next;
|
|
85
|
+
}
|
|
86
|
+
const names = [...new Set(expected.map(([n]) => n))];
|
|
87
|
+
const all = names
|
|
88
|
+
.flatMap((n) => stubOf(n).calls().map((c) => ({ n, c })))
|
|
89
|
+
.sort((a, b) => startOf(a.c) - startOf(b.c))
|
|
90
|
+
.map(({ n, c }) => ` ${n}: ${c.graphql ? describeGraphQL(c.graphql) : `${c.method} ${c.path}`}`);
|
|
91
|
+
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))}` : ""}`;
|
|
92
|
+
return {
|
|
93
|
+
pass: failedAt === -1,
|
|
94
|
+
message: () => failedAt === -1
|
|
95
|
+
? `expected the stubs not to receive, in this order:\n${expected.map((e) => ` ${label(e)}`).join("\n")}`
|
|
96
|
+
: `expected the stubs to receive, in this order:\n${expected.map((e, i) => ` ${i === failedAt ? "✗" : i < failedAt ? "✓" : " "} ${label(e)}`).join("\n")}\n` +
|
|
97
|
+
`${failedAt === 0 ? "the first call never came" : `no matching call came after #${failedAt}`}. Calls received, in order:\n${all.join("\n") || " (no calls)"}`,
|
|
98
|
+
};
|
|
99
|
+
},
|
|
66
100
|
toHaveReceivedGraphQL(received, operation, variables) {
|
|
67
101
|
assertStub(received);
|
|
68
102
|
const pass = received.calls("*", undefined, { graphql: { operation, variables } }).length > 0;
|
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/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();
|
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 {};
|
package/dist/scenario.js
CHANGED
|
@@ -6,25 +6,41 @@ const slot = globalThis;
|
|
|
6
6
|
export function setRuntime(runtime) {
|
|
7
7
|
slot[KEY] = runtime;
|
|
8
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 function tagsSelected(tags = [], filter = process.env.SLICETEST_TAGS) {
|
|
14
|
+
const terms = (filter ?? "").split(/[\s,]+/).filter(Boolean);
|
|
15
|
+
const excluded = terms.filter((t) => t.startsWith("!")).map((t) => t.slice(1));
|
|
16
|
+
const wanted = terms.filter((t) => !t.startsWith("!"));
|
|
17
|
+
if (tags.some((t) => excluded.includes(t)))
|
|
18
|
+
return false;
|
|
19
|
+
return wanted.length === 0 || tags.some((t) => wanted.includes(t));
|
|
20
|
+
}
|
|
9
21
|
function define(register) {
|
|
10
|
-
return (name, body,
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
return (name, body, options) => {
|
|
23
|
+
const { timeout, tags } = typeof options === "number" ? { timeout: options, tags: undefined } : (options ?? {});
|
|
24
|
+
// Scenarios the tag filter leaves out show as skipped, so the filter is visible in the summary.
|
|
25
|
+
return (tagsSelected(tags) ? register : test.skip)(name, async ({ onTestFailed, task }) => {
|
|
26
|
+
const runtime = slot[KEY];
|
|
27
|
+
if (!runtime) {
|
|
28
|
+
throw new Error("slicetest: runtime not started. Add the slicetest() plugin to your vitest config.");
|
|
29
|
+
}
|
|
30
|
+
if (task.concurrent) {
|
|
31
|
+
throw new Error("slicetest: scenarios share one app and database per file, so they can't run concurrently. Remove .concurrent / sequence.concurrent.");
|
|
32
|
+
}
|
|
33
|
+
const file = task.file?.filepath ?? task.file?.name ?? "";
|
|
34
|
+
onTestFailed(async () => {
|
|
35
|
+
console.error(`--- slicetest ---\n${await runtime.diagnostics()}\n-----------------`);
|
|
36
|
+
await runtime.reportDiagram(file, task.name, true);
|
|
37
|
+
});
|
|
38
|
+
await runtime.beforeScenario();
|
|
39
|
+
await body(runtime.context());
|
|
40
|
+
await runtime.afterScenario();
|
|
41
|
+
await runtime.reportDiagram(file, task.name, false);
|
|
42
|
+
}, timeout);
|
|
43
|
+
};
|
|
28
44
|
}
|
|
29
45
|
/**
|
|
30
46
|
* A test that runs against the real app. The database is reset to the
|
|
@@ -36,10 +52,10 @@ export const scenario = Object.assign(define(test), {
|
|
|
36
52
|
todo: (name) => test.todo(name),
|
|
37
53
|
/** Same scenario for each row: `scenario.each(rows)("name %s", async (row, ctx) => ...)`. */
|
|
38
54
|
each(rows) {
|
|
39
|
-
return (name, body,
|
|
55
|
+
return (name, body, options) => {
|
|
40
56
|
rows.forEach((row, i) => {
|
|
41
57
|
const title = format(name, row, i);
|
|
42
|
-
define(test)(title, (ctx) => body(row, ctx),
|
|
58
|
+
define(test)(title, (ctx) => body(row, ctx), options);
|
|
43
59
|
});
|
|
44
60
|
};
|
|
45
61
|
},
|
package/dist/stub.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface RecordedCall {
|
|
|
8
8
|
body: string;
|
|
9
9
|
/** Parsed JSON body, or undefined when the body isn't JSON. */
|
|
10
10
|
json: any;
|
|
11
|
+
/**
|
|
12
|
+
* Fields of an `application/x-www-form-urlencoded` body (Stripe, Twilio, OAuth token requests),
|
|
13
|
+
* with bracket keys nested as the providers read them: `metadata[order]=7&items[0][price]=p_1`
|
|
14
|
+
* is `{ metadata: { order: "7" }, items: [{ price: "p_1" }] }`. For `multipart/form-data`, the
|
|
15
|
+
* fields with files as `{ filename, type, size, text }`. Undefined for other bodies.
|
|
16
|
+
*/
|
|
17
|
+
form?: Record<string, unknown>;
|
|
11
18
|
/** Values captured by `:name` segments of the matching route's path. */
|
|
12
19
|
params: Record<string, string>;
|
|
13
20
|
/** Whether a registered route answered this call. */
|
|
@@ -71,6 +78,8 @@ export interface MatchOptions {
|
|
|
71
78
|
query?: Record<string, Matcher>;
|
|
72
79
|
headers?: Record<string, Matcher>;
|
|
73
80
|
json?: unknown;
|
|
81
|
+
/** Subset of a form-encoded body's fields (`call.form`). Numbers and booleans compare as the strings sent. */
|
|
82
|
+
form?: Record<string, unknown>;
|
|
74
83
|
body?: Matcher;
|
|
75
84
|
/** A GraphQL request for this operation (its name, or a RegExp), with `variables` as a subset. */
|
|
76
85
|
graphql?: {
|
|
@@ -173,4 +182,17 @@ export declare class Stub {
|
|
|
173
182
|
}
|
|
174
183
|
/** `expected` is contained in `actual`: objects compare key by key, arrays element-wise. */
|
|
175
184
|
export declare function subset(expected: unknown, actual: unknown): boolean;
|
|
185
|
+
/**
|
|
186
|
+
* A form body as nested fields, the way Rack, PHP and Stripe read bracket keys: `a[b]=1` is
|
|
187
|
+
* `{ a: { b: "1" } }`, `a[]=1&a[]=2` and `a[0]=1&a[1]=2` are arrays, and a plain key sent
|
|
188
|
+
* twice (`to=1&to=2`) becomes an array too.
|
|
189
|
+
*/
|
|
190
|
+
export declare function parseForm(body: string): Record<string, unknown>;
|
|
191
|
+
/** A file in a multipart body, as `call.form` shows it. `text` only for text, JSON, XML and CSV files. */
|
|
192
|
+
export interface UploadedFile {
|
|
193
|
+
filename: string;
|
|
194
|
+
type: string;
|
|
195
|
+
size: number;
|
|
196
|
+
text?: string;
|
|
197
|
+
}
|
|
176
198
|
export {};
|