slicetest 0.6.1 → 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 +177 -24
- package/dist/cli.js +66 -2
- package/dist/config.d.ts +18 -0
- package/dist/config.js +13 -0
- package/dist/connection.d.ts +5 -0
- package/dist/connection.js +31 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/diagram.d.ts +13 -0
- package/dist/diagram.js +89 -0
- 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 +74 -9
- package/dist/graphql.d.ts +34 -0
- package/dist/graphql.js +56 -0
- package/dist/har.d.ts +49 -0
- package/dist/har.js +107 -0
- package/dist/http.d.ts +21 -3
- package/dist/http.js +47 -10
- package/dist/index.d.ts +5 -3
- package/dist/init.js +184 -15
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +16 -0
- package/dist/matchers.js +81 -1
- package/dist/openapi.d.ts +21 -0
- package/dist/openapi.js +32 -1
- package/dist/provided.d.ts +1 -0
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/recording.d.ts +1 -1
- package/dist/recording.js +2 -2
- package/dist/runtime.d.ts +12 -5
- package/dist/runtime.js +78 -22
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -17
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +73 -0
- package/dist/stub.d.ts +63 -0
- package/dist/stub.js +292 -5
- package/dist/timeline.d.ts +9 -0
- package/dist/timeline.js +6 -0
- 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 +308 -51
- package/dist/yaml.d.ts +82 -6
- package/dist/yaml.js +187 -21
- package/package.json +2 -1
- package/schema/scenario.schema.json +640 -14
package/dist/recording.d.ts
CHANGED
|
@@ -40,4 +40,4 @@ export declare class Recorder {
|
|
|
40
40
|
}
|
|
41
41
|
export declare function readRecordings(file: string): Promise<Recording[]>;
|
|
42
42
|
/** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
|
|
43
|
-
export declare function mergeRecordings(file: string, upstream: string, added: Recording[]): Promise<void>;
|
|
43
|
+
export declare function mergeRecordings(file: string, upstream: string, added: Recording[], source?: string): Promise<void>;
|
package/dist/recording.js
CHANGED
|
@@ -137,12 +137,12 @@ export async function readRecordings(file) {
|
|
|
137
137
|
return doc;
|
|
138
138
|
}
|
|
139
139
|
/** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
|
|
140
|
-
export async function mergeRecordings(file, upstream, added) {
|
|
140
|
+
export async function mergeRecordings(file, upstream, added, source = `Recorded by slicetest from ${upstream}`) {
|
|
141
141
|
const entries = await readRecordings(file);
|
|
142
142
|
for (const e of added)
|
|
143
143
|
if (!entries.some((x) => isDeepStrictEqual(x, e)))
|
|
144
144
|
entries.push(e);
|
|
145
145
|
await mkdir(path.dirname(file), { recursive: true });
|
|
146
|
-
const header = `#
|
|
146
|
+
const header = `# ${source}. Review before committing: request bodies are stored as sent.\n`;
|
|
147
147
|
await writeFile(file, header + YAML.stringify(entries, { lineWidth: 0 }));
|
|
148
148
|
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { App } from "./app.js";
|
|
|
2
2
|
import { Issuer } from "./auth.js";
|
|
3
3
|
import type { ResolvedOptions } from "./config.js";
|
|
4
4
|
import { Dependency } from "./containers.js";
|
|
5
|
+
import { connectionVars } from "./connection.js";
|
|
5
6
|
import { Db } from "./db.js";
|
|
6
7
|
import { HttpClient } from "./http.js";
|
|
7
8
|
import { Interceptor } from "./intercept.js";
|
|
@@ -24,6 +25,8 @@ export interface ScenarioContext {
|
|
|
24
25
|
* with timestamps and UUIDs masked. `expect(await trace()).toMatchSnapshot()`.
|
|
25
26
|
*/
|
|
26
27
|
trace: (opts?: MaskOptions) => Promise<Trace>;
|
|
28
|
+
/** The scenario so far as a Mermaid sequence diagram: requests, stub calls, mail and changed tables. */
|
|
29
|
+
diagram: () => Promise<string>;
|
|
27
30
|
/** Mail the app sent during the scenario. Needs `mail: true` in the config. */
|
|
28
31
|
mail: Mailbox;
|
|
29
32
|
/** The OpenID Connect issuer the app trusts: `auth.token(claims)`. Needs `auth` in the config. */
|
|
@@ -56,20 +59,24 @@ export declare class Runtime {
|
|
|
56
59
|
prefix: string;
|
|
57
60
|
coverageDir?: string;
|
|
58
61
|
recordDir?: string;
|
|
62
|
+
usageDir?: string;
|
|
59
63
|
}): Promise<Runtime>;
|
|
60
64
|
context(): ScenarioContext;
|
|
61
65
|
beforeScenario(): Promise<void>;
|
|
62
66
|
/** Failures that the scenario body can't see on its own. */
|
|
63
67
|
afterScenario(): Promise<void>;
|
|
68
|
+
/** The current scenario as a Mermaid sequence diagram. */
|
|
69
|
+
diagram(): Promise<string>;
|
|
70
|
+
/**
|
|
71
|
+
* After a scenario: its diagram goes to the `SLICETEST_DIAGRAMS` directory (one Markdown page
|
|
72
|
+
* per test file) and, when it failed on GitHub Actions, to the job summary.
|
|
73
|
+
*/
|
|
74
|
+
reportDiagram(file: string, scenario: string, failed: boolean, env?: NodeJS.ProcessEnv): Promise<void>;
|
|
64
75
|
/** What happened during the current scenario, printed when it fails. */
|
|
65
76
|
diagnostics(): Promise<string>;
|
|
66
77
|
/** Hand the coverage and recordings gathered so far to the run (merged when it ends). */
|
|
67
78
|
flush(): Promise<void>;
|
|
68
79
|
stop(): Promise<void>;
|
|
69
80
|
}
|
|
70
|
-
/**
|
|
71
|
-
* The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
|
|
72
|
-
* `spring.datasource.url` plus username / password), or separate host / port / name settings.
|
|
73
|
-
*/
|
|
74
|
-
export declare function connectionVars(engine: string, url: string, sqlitePath?: string): Record<string, string>;
|
|
75
81
|
export declare function blockedHint(hosts: string[]): string;
|
|
82
|
+
export { connectionVars };
|
package/dist/runtime.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { App } from "./app.js";
|
|
6
6
|
import { Issuer } from "./auth.js";
|
|
7
7
|
import { Dependency } from "./containers.js";
|
|
8
|
+
import { connectionVars } from "./connection.js";
|
|
8
9
|
import { Db, formatChanges, noDatabase } from "./db.js";
|
|
10
|
+
import { diagramPage, failureDiagram, sequenceDiagram } from "./diagram.js";
|
|
11
|
+
import { appendSummary } from "./ci.js";
|
|
9
12
|
import { engineFor } from "./drivers/index.js";
|
|
10
13
|
import { formatHistory, HttpClient } from "./http.js";
|
|
11
14
|
import { Interceptor } from "./intercept.js";
|
|
@@ -39,6 +42,8 @@ export class Runtime {
|
|
|
39
42
|
#contract = [];
|
|
40
43
|
/** Documented responses seen in this file, for the run's coverage report. */
|
|
41
44
|
#covered = new Set();
|
|
45
|
+
/** Operations of stubbed providers (with an `openapi` spec) the app called, per stub, for the usage report. */
|
|
46
|
+
#used = new Map();
|
|
42
47
|
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox, issuer, queryLog, interceptor, neon) {
|
|
43
48
|
this.app = app;
|
|
44
49
|
this.services = services;
|
|
@@ -119,6 +124,7 @@ export class Runtime {
|
|
|
119
124
|
? await Db.connect(await engine.driver(url), url, {
|
|
120
125
|
schemas: opts.db.schemas,
|
|
121
126
|
keep: opts.db.keep,
|
|
127
|
+
ignoreChanges: opts.db.ignoreChanges,
|
|
122
128
|
seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
|
|
123
129
|
})
|
|
124
130
|
: noDatabase();
|
|
@@ -182,7 +188,9 @@ export class Runtime {
|
|
|
182
188
|
const app = await App.start(opts.app, opts.root, vars);
|
|
183
189
|
if (opts.openapi.fromApp)
|
|
184
190
|
specs.app = await fetchAppSpec(app.url, opts.openapi.fromApp, shared.coverageDir);
|
|
185
|
-
|
|
191
|
+
const runtime = new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox, issuer, queryLog, interceptor, neon);
|
|
192
|
+
runtime.#usageDir = shared.usageDir;
|
|
193
|
+
return runtime;
|
|
186
194
|
}
|
|
187
195
|
catch (e) {
|
|
188
196
|
await Promise.all([...services.values()].map((s) => s.stop()));
|
|
@@ -227,6 +235,7 @@ export class Runtime {
|
|
|
227
235
|
return c;
|
|
228
236
|
},
|
|
229
237
|
trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart(), this.mailbox), opts),
|
|
238
|
+
diagram: () => this.diagram(),
|
|
230
239
|
get mail() {
|
|
231
240
|
if (!mailbox)
|
|
232
241
|
throw new Error("slicetest: mail is off. Add `mail: true` to the config and point the app's SMTP settings at {{mail.host}} / {{mail.port}}.");
|
|
@@ -243,7 +252,22 @@ export class Runtime {
|
|
|
243
252
|
#processes() {
|
|
244
253
|
return [this.app, ...this.services.values()];
|
|
245
254
|
}
|
|
255
|
+
#usageDir;
|
|
256
|
+
/** Note which provider operations the stubs were called for, before their calls are cleared. */
|
|
257
|
+
#collectUsage() {
|
|
258
|
+
for (const [name, spec] of this.specs.stubs) {
|
|
259
|
+
const used = this.#used.get(name) ?? new Set();
|
|
260
|
+
for (const call of this.stubs.get(name).calls()) {
|
|
261
|
+
const op = spec.operationOf(call.method, call.path);
|
|
262
|
+
if (op)
|
|
263
|
+
used.add(op.key);
|
|
264
|
+
}
|
|
265
|
+
if (used.size)
|
|
266
|
+
this.#used.set(name, used);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
246
269
|
async beforeScenario() {
|
|
270
|
+
this.#collectUsage();
|
|
247
271
|
// A crash already failed the scenario that caused it; give the next one a fresh process.
|
|
248
272
|
for (const [name, service] of this.services) {
|
|
249
273
|
if (!service.exited)
|
|
@@ -286,6 +310,14 @@ export class Runtime {
|
|
|
286
310
|
if (contract.length > 0) {
|
|
287
311
|
throw new Error(`slicetest: traffic doesn't match the OpenAPI spec:\n${contract.map((c) => ` ${c}`).join("\n")}`);
|
|
288
312
|
}
|
|
313
|
+
const unused = this.#unusedRoutes();
|
|
314
|
+
if (this.opts.strictStubs && unused.length > 0) {
|
|
315
|
+
throw new Error(`slicetest: stub routes the app never called (strictStubs):\n${unused.join("\n")}\n` +
|
|
316
|
+
"Remove them, check that the scenario reaches the code that calls them, or mark them .optional() (YAML: optional: true).");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
#unusedRoutes() {
|
|
320
|
+
return [...this.stubs.values()].flatMap((s) => s.unusedRoutes().map((r) => ` ${s.name}: ${r}`));
|
|
289
321
|
}
|
|
290
322
|
/** The app's responses against its spec, and its calls to stubs (and the stubs' replies) against theirs. */
|
|
291
323
|
#contractViolations() {
|
|
@@ -322,12 +354,45 @@ export class Runtime {
|
|
|
322
354
|
return [];
|
|
323
355
|
const routes = s.describeRoutes();
|
|
324
356
|
return [
|
|
325
|
-
...calls.
|
|
357
|
+
...calls.flatMap((c) => {
|
|
358
|
+
const why = s.explain(c);
|
|
359
|
+
return [` ${s.name}: ${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`, ...(why ? [` ${why}`] : [])];
|
|
360
|
+
}),
|
|
326
361
|
` registered on ${s.name}: ${routes.length ? routes.join(", ") : "(none)"}`,
|
|
327
362
|
...(this.recorders.has(s.name) ? [` ${this.recorders.get(s.name).hint()}`] : []),
|
|
328
363
|
];
|
|
329
364
|
});
|
|
330
365
|
}
|
|
366
|
+
/** The current scenario as a Mermaid sequence diagram. */
|
|
367
|
+
async diagram() {
|
|
368
|
+
const changes = this.opts.db.none ? undefined : await this.db.changesSinceStart().catch(() => undefined);
|
|
369
|
+
return sequenceDiagram(this.http.history, this.stubs.values(), changes, this.mailbox);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* After a scenario: its diagram goes to the `SLICETEST_DIAGRAMS` directory (one Markdown page
|
|
373
|
+
* per test file) and, when it failed on GitHub Actions, to the job summary.
|
|
374
|
+
*/
|
|
375
|
+
async reportDiagram(file, scenario, failed, env = process.env) {
|
|
376
|
+
const dir = env.SLICETEST_DIAGRAMS;
|
|
377
|
+
const summary = failed && env.GITHUB_STEP_SUMMARY;
|
|
378
|
+
if (!dir && !summary)
|
|
379
|
+
return;
|
|
380
|
+
let diagram;
|
|
381
|
+
try {
|
|
382
|
+
diagram = await this.diagram();
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const rel = path.relative(this.opts.root, file).replace(/\\/g, "/");
|
|
388
|
+
if (summary)
|
|
389
|
+
await appendSummary(failureDiagram(rel, scenario, diagram), env);
|
|
390
|
+
if (dir) {
|
|
391
|
+
const out = path.resolve(this.opts.root, dir, `${rel.replace(/^(\.\.\/)+/, "")}.md`);
|
|
392
|
+
await mkdir(path.dirname(out), { recursive: true });
|
|
393
|
+
await writeFile(out, diagramPage(out, rel, scenario, diagram, failed)).catch(() => { });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
331
396
|
/** What happened during the current scenario, printed when it fails. */
|
|
332
397
|
async diagnostics() {
|
|
333
398
|
const sections = [];
|
|
@@ -339,6 +404,9 @@ export class Runtime {
|
|
|
339
404
|
const unmatched = this.#unmatched();
|
|
340
405
|
if (unmatched.length > 0)
|
|
341
406
|
sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
|
|
407
|
+
const unused = this.#unusedRoutes();
|
|
408
|
+
if (unused.length > 0)
|
|
409
|
+
sections.push(`stub routes the app never called:\n${unused.join("\n")}`);
|
|
342
410
|
const chaos = [...this.stubs.values()].map((s) => s.describeChaos()).filter((c) => c !== undefined);
|
|
343
411
|
if (chaos.length > 0)
|
|
344
412
|
sections.push(chaos.join("\n"));
|
|
@@ -375,6 +443,12 @@ export class Runtime {
|
|
|
375
443
|
}
|
|
376
444
|
/** Hand the coverage and recordings gathered so far to the run (merged when it ends). */
|
|
377
445
|
async flush() {
|
|
446
|
+
this.#collectUsage();
|
|
447
|
+
if (this.#usageDir && this.#used.size > 0) {
|
|
448
|
+
const data = Object.fromEntries([...this.#used].map(([k, v]) => [k, [...v]]));
|
|
449
|
+
await writeFile(path.join(this.#usageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify(data)).catch(() => { });
|
|
450
|
+
this.#used.clear();
|
|
451
|
+
}
|
|
378
452
|
if (this.coverageDir && this.#covered.size > 0) {
|
|
379
453
|
await writeFile(path.join(this.coverageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify([...this.#covered])).catch(() => { });
|
|
380
454
|
this.#covered.clear();
|
|
@@ -439,25 +513,6 @@ function parseJson(text) {
|
|
|
439
513
|
return undefined;
|
|
440
514
|
}
|
|
441
515
|
}
|
|
442
|
-
/**
|
|
443
|
-
* The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
|
|
444
|
-
* `spring.datasource.url` plus username / password), or separate host / port / name settings.
|
|
445
|
-
*/
|
|
446
|
-
export function connectionVars(engine, url, sqlitePath) {
|
|
447
|
-
if (engine === "sqlite")
|
|
448
|
-
return { "db.jdbcUrl": `jdbc:sqlite:${sqlitePath}` };
|
|
449
|
-
const u = new URL(url);
|
|
450
|
-
const port = u.port || (engine === "mysql" ? "3306" : "5432");
|
|
451
|
-
const name = decodeURIComponent(u.pathname.replace(/^\//, ""));
|
|
452
|
-
return {
|
|
453
|
-
"db.host": u.hostname,
|
|
454
|
-
"db.port": port,
|
|
455
|
-
"db.name": name,
|
|
456
|
-
"db.user": decodeURIComponent(u.username),
|
|
457
|
-
"db.password": decodeURIComponent(u.password),
|
|
458
|
-
"db.jdbcUrl": `jdbc:${engine === "mysql" ? "mysql" : "postgresql"}://${u.hostname}:${port}/${encodeURIComponent(name)}`,
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
516
|
/** The app's own spec, served at `route` (springdoc's /v3/api-docs, FastAPI's /openapi.json, …). */
|
|
462
517
|
async function fetchAppSpec(appUrl, route, coverageDir) {
|
|
463
518
|
let text;
|
|
@@ -483,3 +538,4 @@ export function blockedHint(hosts) {
|
|
|
483
538
|
return message;
|
|
484
539
|
return `${message}\n${registries.join(", ")} ${registries.length > 1 ? "are package registries" : "is a package registry"}: the command that starts the app (gradle bootRun, mvn spring-boot:run, go run, …) is downloading dependencies, through slicetest's proxy. Download them in \`app.build\` (./gradlew bootJar, mvn package), or start a built artifact (java -jar).`;
|
|
485
540
|
}
|
|
541
|
+
export { connectionVars };
|
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,22 +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
|
-
|
|
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
|
+
};
|
|
25
44
|
}
|
|
26
45
|
/**
|
|
27
46
|
* A test that runs against the real app. The database is reset to the
|
|
@@ -33,10 +52,10 @@ export const scenario = Object.assign(define(test), {
|
|
|
33
52
|
todo: (name) => test.todo(name),
|
|
34
53
|
/** Same scenario for each row: `scenario.each(rows)("name %s", async (row, ctx) => ...)`. */
|
|
35
54
|
each(rows) {
|
|
36
|
-
return (name, body,
|
|
55
|
+
return (name, body, options) => {
|
|
37
56
|
rows.forEach((row, i) => {
|
|
38
57
|
const title = format(name, row, i);
|
|
39
|
-
define(test)(title, (ctx) => body(row, ctx),
|
|
58
|
+
define(test)(title, (ctx) => body(row, ctx), options);
|
|
40
59
|
});
|
|
41
60
|
};
|
|
42
61
|
},
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ValidateFunction } from "ajv";
|
|
2
|
+
/** A schema file and pointer, `schemas/poll.json` or `openapi.yaml#/components/schemas/Poll`, resolved against `base`. */
|
|
3
|
+
export declare function validatorFor(ref: string, base?: string): ValidateFunction;
|
|
4
|
+
/** Problems with `value` against `schema` (an object, or a reference as for `validatorFor`); empty when it matches. */
|
|
5
|
+
export declare function schemaProblems(schema: object | string, value: unknown, base?: string): string[];
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Ajv } from "ajv";
|
|
4
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
5
|
+
import addFormatsModule from "ajv-formats";
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
import { nullableToType } from "./openapi.js";
|
|
8
|
+
/**
|
|
9
|
+
* JSON Schema checks for `toMatchSchema()` and YAML `expect.schema`: an inline
|
|
10
|
+
* schema, or a file with an optional JSON pointer, such as an OpenAPI component
|
|
11
|
+
* (`openapi.yaml#/components/schemas/Poll`), whose `$ref`s resolve within that file.
|
|
12
|
+
*/
|
|
13
|
+
const addFormats = (addFormatsModule.default ?? addFormatsModule);
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
function ajvFor(draft2020) {
|
|
16
|
+
const ajv = draft2020 ? new Ajv2020({ strict: false, allErrors: true }) : new Ajv({ strict: false, allErrors: true });
|
|
17
|
+
addFormats(ajv);
|
|
18
|
+
// OpenAPI's own formats, which JSON Schema doesn't define.
|
|
19
|
+
for (const f of ["int32", "int64", "float", "double", "byte", "binary", "password"])
|
|
20
|
+
ajv.addFormat(f, true);
|
|
21
|
+
return ajv;
|
|
22
|
+
}
|
|
23
|
+
/** A schema file and pointer, `schemas/poll.json` or `openapi.yaml#/components/schemas/Poll`, resolved against `base`. */
|
|
24
|
+
export function validatorFor(ref, base = process.cwd()) {
|
|
25
|
+
const [file, pointer = ""] = ref.split("#");
|
|
26
|
+
const abs = path.resolve(base, file);
|
|
27
|
+
const key = `${abs}#${pointer}`;
|
|
28
|
+
const hit = cache.get(key);
|
|
29
|
+
if (hit)
|
|
30
|
+
return hit;
|
|
31
|
+
let doc;
|
|
32
|
+
try {
|
|
33
|
+
doc = parse(readFileSync(abs, "utf8"));
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
throw new Error(`slicetest: can't read schema ${file}: ${e.message}`);
|
|
37
|
+
}
|
|
38
|
+
if (!doc || typeof doc !== "object")
|
|
39
|
+
throw new Error(`slicetest: schema ${file} is not a JSON or YAML document`);
|
|
40
|
+
const openapi = typeof doc.openapi === "string";
|
|
41
|
+
if (openapi && !pointer)
|
|
42
|
+
throw new Error(`slicetest: ${file} is an OpenAPI document; point at a schema in it, e.g. ${file}#/components/schemas/Poll`);
|
|
43
|
+
// OpenAPI 3.0 schemas are a dialect of draft-04/07 with `nullable`; 3.1 and plain files are 2020-12 unless they say otherwise.
|
|
44
|
+
const draft2020 = openapi ? doc.openapi.startsWith("3.1") : !/draft-0[4-7]/.test(String(doc.$schema ?? ""));
|
|
45
|
+
const ajv = ajvFor(draft2020);
|
|
46
|
+
ajv.addSchema(openapi && !draft2020 ? nullableToType(structuredClone(doc)) : doc, "doc");
|
|
47
|
+
let validate;
|
|
48
|
+
try {
|
|
49
|
+
validate = ajv.compile({ $ref: `doc#${pointer}` });
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
throw new Error(`slicetest: no schema at ${ref}: ${e.message}`);
|
|
53
|
+
}
|
|
54
|
+
cache.set(key, validate);
|
|
55
|
+
return validate;
|
|
56
|
+
}
|
|
57
|
+
const inline = new WeakMap();
|
|
58
|
+
/** Problems with `value` against `schema` (an object, or a reference as for `validatorFor`); empty when it matches. */
|
|
59
|
+
export function schemaProblems(schema, value, base) {
|
|
60
|
+
let validate;
|
|
61
|
+
if (typeof schema === "string")
|
|
62
|
+
validate = validatorFor(schema, base);
|
|
63
|
+
else {
|
|
64
|
+
validate = inline.get(schema) ?? ajvFor(!/draft-0[4-7]/.test(String(schema.$schema ?? ""))).compile(schema);
|
|
65
|
+
inline.set(schema, validate);
|
|
66
|
+
}
|
|
67
|
+
if (validate(value))
|
|
68
|
+
return [];
|
|
69
|
+
return (validate.errors ?? []).map((e) => {
|
|
70
|
+
const extra = e.keyword === "additionalProperties" ? ` (${e.params.additionalProperty})` : e.keyword === "enum" ? ` (${e.params.allowedValues?.map((v) => JSON.stringify(v)).join(", ")})` : "";
|
|
71
|
+
return `${e.instancePath || "(root)"} ${e.message}${extra}`;
|
|
72
|
+
});
|
|
73
|
+
}
|
package/dist/stub.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import { type GraphQLCall } from "./graphql.js";
|
|
2
3
|
export interface RecordedCall {
|
|
3
4
|
method: string;
|
|
4
5
|
path: string;
|
|
@@ -7,6 +8,13 @@ export interface RecordedCall {
|
|
|
7
8
|
body: string;
|
|
8
9
|
/** Parsed JSON body, or undefined when the body isn't JSON. */
|
|
9
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>;
|
|
10
18
|
/** Values captured by `:name` segments of the matching route's path. */
|
|
11
19
|
params: Record<string, string>;
|
|
12
20
|
/** Whether a registered route answered this call. */
|
|
@@ -21,6 +29,8 @@ export interface RecordedCall {
|
|
|
21
29
|
fallback?: boolean;
|
|
22
30
|
/** The fault `chaos()` injected instead of the normal answer: `503`, `reset`. */
|
|
23
31
|
fault?: string;
|
|
32
|
+
/** The GraphQL operation, when the call is a GraphQL request. */
|
|
33
|
+
graphql?: GraphQLCall;
|
|
24
34
|
}
|
|
25
35
|
/**
|
|
26
36
|
* Faults injected into a stub's answers, to test the app's retries, timeouts and
|
|
@@ -68,7 +78,14 @@ export interface MatchOptions {
|
|
|
68
78
|
query?: Record<string, Matcher>;
|
|
69
79
|
headers?: Record<string, Matcher>;
|
|
70
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>;
|
|
71
83
|
body?: Matcher;
|
|
84
|
+
/** A GraphQL request for this operation (its name, or a RegExp), with `variables` as a subset. */
|
|
85
|
+
graphql?: {
|
|
86
|
+
operation?: string | RegExp;
|
|
87
|
+
variables?: unknown;
|
|
88
|
+
};
|
|
72
89
|
}
|
|
73
90
|
type Matcher = string | number | boolean | RegExp | ((value: any) => boolean) | {
|
|
74
91
|
asymmetricMatch(value: unknown): boolean;
|
|
@@ -80,6 +97,8 @@ export interface RouteBuilder {
|
|
|
80
97
|
once(): RouteBuilder;
|
|
81
98
|
/** Wait before answering, e.g. to exercise the app's timeouts. */
|
|
82
99
|
delay(ms: number): RouteBuilder;
|
|
100
|
+
/** The app may or may not call this route: it isn't reported as unused (`strictStubs`). */
|
|
101
|
+
optional(): RouteBuilder;
|
|
83
102
|
reply(status: number, body?: unknown, headers?: Record<string, string>): Stub;
|
|
84
103
|
reply(response: Responder): Stub;
|
|
85
104
|
/** Answer each matching call with the next response in the list; the last one repeats. */
|
|
@@ -87,6 +106,21 @@ export interface RouteBuilder {
|
|
|
87
106
|
/** Drop the connection without answering. */
|
|
88
107
|
networkError(): Stub;
|
|
89
108
|
}
|
|
109
|
+
/** Builder returned by `stub.graphql()`: `reply()` as usual, or `data()` / `errors()` for a GraphQL answer. */
|
|
110
|
+
export interface GraphQLRouteBuilder extends RouteBuilder {
|
|
111
|
+
times(n: number): GraphQLRouteBuilder;
|
|
112
|
+
once(): GraphQLRouteBuilder;
|
|
113
|
+
delay(ms: number): GraphQLRouteBuilder;
|
|
114
|
+
optional(): GraphQLRouteBuilder;
|
|
115
|
+
/** Answer `{ data }` (a function receives the call, with `call.graphql.variables`). */
|
|
116
|
+
data(data: (call: RecordedCall) => unknown): Stub;
|
|
117
|
+
data(data: unknown): Stub;
|
|
118
|
+
/** Answer `{ errors, data }` with status 200, as GraphQL servers report resolver errors. */
|
|
119
|
+
errors(errors: (string | {
|
|
120
|
+
message: string;
|
|
121
|
+
[k: string]: unknown;
|
|
122
|
+
})[], data?: unknown): Stub;
|
|
123
|
+
}
|
|
90
124
|
/**
|
|
91
125
|
* A fake outbound service. The app is pointed at `url`; tests register routes
|
|
92
126
|
* with `on()` and inspect what the app sent with `calls()`.
|
|
@@ -105,6 +139,15 @@ export declare class Stub {
|
|
|
105
139
|
* `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
|
|
106
140
|
*/
|
|
107
141
|
on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
|
|
142
|
+
/**
|
|
143
|
+
* Answer a GraphQL operation, whatever path the app posts it to:
|
|
144
|
+
* `stub("github").graphql("CreateIssue", { variables: { title: "Bug" } }).data({ createIssue: { issue: { number: 1 } } })`.
|
|
145
|
+
* The operation is `operationName`, or the name in the document when the client sends none.
|
|
146
|
+
*/
|
|
147
|
+
graphql(operation: string | RegExp, match?: Omit<MatchOptions, "graphql"> & {
|
|
148
|
+
variables?: unknown;
|
|
149
|
+
path?: string | RegExp;
|
|
150
|
+
}): GraphQLRouteBuilder;
|
|
108
151
|
/**
|
|
109
152
|
* Inject faults into this stub's answers for the rest of the scenario:
|
|
110
153
|
* `stub("payments").chaos({ failFirst: 2 })` to test a retry,
|
|
@@ -119,8 +162,15 @@ export declare class Stub {
|
|
|
119
162
|
/** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
|
|
120
163
|
calls(method?: string, path?: string | RegExp, match?: MatchOptions): RecordedCall[];
|
|
121
164
|
unmatched(): RecordedCall[];
|
|
165
|
+
/** Routes registered in this scenario that no call reached, except `optional()` ones. */
|
|
166
|
+
unusedRoutes(): string[];
|
|
122
167
|
/** Human-readable list of registered routes, for diagnostics. */
|
|
123
168
|
describeRoutes(): string[];
|
|
169
|
+
/**
|
|
170
|
+
* Why `call` wasn't answered, measured against the registered route it came closest to:
|
|
171
|
+
* `closest route POST /v1/charges: json.amount: expected 100, got "100"`. Undefined without routes.
|
|
172
|
+
*/
|
|
173
|
+
explain(call: RecordedCall): string | undefined;
|
|
124
174
|
reset(): void;
|
|
125
175
|
/**
|
|
126
176
|
* Answer calls that no registered route matches, instead of failing with 501.
|
|
@@ -132,4 +182,17 @@ export declare class Stub {
|
|
|
132
182
|
}
|
|
133
183
|
/** `expected` is contained in `actual`: objects compare key by key, arrays element-wise. */
|
|
134
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
|
+
}
|
|
135
198
|
export {};
|