slicetest 0.2.0 → 0.3.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 +90 -7
- package/dist/cli.js +29 -3
- package/dist/config.d.ts +52 -8
- package/dist/config.js +41 -2
- package/dist/containers.d.ts +22 -0
- package/dist/containers.js +59 -0
- package/dist/drivers/index.d.ts +2 -2
- package/dist/drivers/index.js +11 -2
- package/dist/drivers/mysql.d.ts +29 -0
- package/dist/drivers/mysql.js +270 -0
- package/dist/gen.d.ts +24 -0
- package/dist/gen.js +116 -0
- package/dist/global-setup.js +30 -3
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -0
- package/dist/init.d.ts +2 -0
- package/dist/init.js +104 -2
- package/dist/openapi.d.ts +18 -0
- package/dist/openapi.js +32 -0
- package/dist/provided.d.ts +1 -0
- package/dist/recording.d.ts +43 -0
- package/dist/recording.js +148 -0
- package/dist/runtime.d.ts +13 -0
- package/dist/runtime.js +50 -6
- package/dist/stub.d.ts +1 -1
- package/dist/stub.js +14 -3
- package/dist/trace.d.ts +35 -0
- package/dist/trace.js +57 -0
- package/dist/yaml-runtime.js +6 -0
- package/dist/yaml.d.ts +7 -1
- package/dist/yaml.js +7 -0
- package/package.json +15 -2
- package/schema/scenario.schema.json +24 -0
package/dist/openapi.js
CHANGED
|
@@ -128,6 +128,38 @@ export class OpenApiSpec {
|
|
|
128
128
|
: this.#sample(m.schema);
|
|
129
129
|
return { status, headers: { "content-type": media === "*/*" ? "application/json" : media }, body: isJson(media) ? body : String(body ?? "") };
|
|
130
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Every operation with what it takes to call it: sample values for its
|
|
133
|
+
* required path and query parameters and for its JSON request body, and the
|
|
134
|
+
* properties of its first 2xx JSON response. Used by `slicetest gen`.
|
|
135
|
+
*/
|
|
136
|
+
operations() {
|
|
137
|
+
return this.#documentOrder.map(({ template, method, op }) => {
|
|
138
|
+
const params = [...(this.doc.paths[template].parameters ?? []), ...(op.parameters ?? [])].map((p) => this.#deref(p)).filter(Boolean);
|
|
139
|
+
const sample = (p) => (p.example !== undefined ? p.example : p.schema ? this.#sample(p.schema) : "1");
|
|
140
|
+
const pathParams = Object.fromEntries(params.filter((p) => p.in === "path").map((p) => [p.name, sample(p)]));
|
|
141
|
+
const query = Object.fromEntries(params.filter((p) => p.in === "query" && p.required).map((p) => [p.name, sample(p)]));
|
|
142
|
+
const body = this.#deref(op.requestBody);
|
|
143
|
+
const media = body?.content && Object.keys(body.content).find(isJson);
|
|
144
|
+
const m = media ? body.content[media] : undefined;
|
|
145
|
+
const json = m ? (m.example !== undefined ? m.example : this.#sample(m.schema)) : undefined;
|
|
146
|
+
const responses = Object.keys(op.responses ?? {});
|
|
147
|
+
const ok = responses.filter((k) => /^2/.test(k)).sort()[0];
|
|
148
|
+
const okContent = ok ? this.#deref(op.responses[ok])?.content : undefined;
|
|
149
|
+
const okMedia = okContent && Object.keys(okContent).find(isJson);
|
|
150
|
+
const okSchema = okMedia ? this.#deref(okContent[okMedia]?.schema) : undefined;
|
|
151
|
+
return {
|
|
152
|
+
method: method.toUpperCase(),
|
|
153
|
+
template,
|
|
154
|
+
summary: op.summary ?? op.operationId,
|
|
155
|
+
pathParams,
|
|
156
|
+
query,
|
|
157
|
+
json: json ?? undefined,
|
|
158
|
+
responses,
|
|
159
|
+
createdFields: okSchema?.properties ? Object.keys(okSchema.properties) : [],
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
}
|
|
131
163
|
/** A value that satisfies `schema` (as far as a simple walk can): examples, defaults, enums, then types. */
|
|
132
164
|
#sample(schema, depth = 0) {
|
|
133
165
|
const s = this.#deref(schema);
|
package/dist/provided.d.ts
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RecordedCall, StubResponse } from "./stub.js";
|
|
2
|
+
/** One recorded exchange with the real service, as stored in the recordings file. */
|
|
3
|
+
export interface Recording {
|
|
4
|
+
request: {
|
|
5
|
+
method: string;
|
|
6
|
+
path: string;
|
|
7
|
+
query?: Record<string, string>;
|
|
8
|
+
json?: unknown;
|
|
9
|
+
body?: string;
|
|
10
|
+
};
|
|
11
|
+
response: {
|
|
12
|
+
status: number;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
json?: unknown;
|
|
15
|
+
body?: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Replays recorded exchanges for calls no route matches and, in record mode,
|
|
20
|
+
* forwards the calls it has no recording for to the real service and records
|
|
21
|
+
* the answer. Identical requests replay their recordings in order within a
|
|
22
|
+
* scenario; the last one repeats.
|
|
23
|
+
*/
|
|
24
|
+
export declare class Recorder {
|
|
25
|
+
#private;
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly file: string;
|
|
28
|
+
readonly upstream: string;
|
|
29
|
+
readonly recording: boolean;
|
|
30
|
+
private constructor();
|
|
31
|
+
static load(name: string, file: string, upstream: string, recording: boolean): Promise<Recorder>;
|
|
32
|
+
/** What recording a call gets, or undefined to leave it unanswered. */
|
|
33
|
+
answer(call: RecordedCall): Promise<StubResponse | undefined>;
|
|
34
|
+
/** Why a call went unanswered, for the stub's 501 reply. */
|
|
35
|
+
hint(): string;
|
|
36
|
+
/** Start of a scenario: identical requests replay from their first recording again. */
|
|
37
|
+
reset(): void;
|
|
38
|
+
/** Recordings made by this worker, to be merged into the file when the run ends. */
|
|
39
|
+
added(): Recording[];
|
|
40
|
+
}
|
|
41
|
+
export declare function readRecordings(file: string): Promise<Recording[]>;
|
|
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>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
/**
|
|
6
|
+
* Response headers worth keeping. Everything else (dates, cookies, rate-limit
|
|
7
|
+
* counters, request ids) is noise in a committed file, or a secret.
|
|
8
|
+
*/
|
|
9
|
+
const KEPT_HEADERS = ["content-type", "location", "retry-after", "link", "etag"];
|
|
10
|
+
/** Request headers not forwarded to the real service. */
|
|
11
|
+
const HOP_HEADERS = ["host", "connection", "content-length", "accept-encoding", "transfer-encoding", "keep-alive"];
|
|
12
|
+
/**
|
|
13
|
+
* Replays recorded exchanges for calls no route matches and, in record mode,
|
|
14
|
+
* forwards the calls it has no recording for to the real service and records
|
|
15
|
+
* the answer. Identical requests replay their recordings in order within a
|
|
16
|
+
* scenario; the last one repeats.
|
|
17
|
+
*/
|
|
18
|
+
export class Recorder {
|
|
19
|
+
name;
|
|
20
|
+
file;
|
|
21
|
+
upstream;
|
|
22
|
+
recording;
|
|
23
|
+
#entries;
|
|
24
|
+
#added = [];
|
|
25
|
+
#seen = new Map();
|
|
26
|
+
constructor(name, file, upstream, recording, entries) {
|
|
27
|
+
this.name = name;
|
|
28
|
+
this.file = file;
|
|
29
|
+
this.upstream = upstream;
|
|
30
|
+
this.recording = recording;
|
|
31
|
+
this.#entries = entries;
|
|
32
|
+
}
|
|
33
|
+
static async load(name, file, upstream, recording) {
|
|
34
|
+
return new Recorder(name, file, upstream, recording, await readRecordings(file));
|
|
35
|
+
}
|
|
36
|
+
/** What recording a call gets, or undefined to leave it unanswered. */
|
|
37
|
+
async answer(call) {
|
|
38
|
+
const request = requestOf(call);
|
|
39
|
+
const key = keyOf(request);
|
|
40
|
+
const matches = this.#entries.filter((e) => keyOf(e.request) === key);
|
|
41
|
+
const n = this.#seen.get(key) ?? 0;
|
|
42
|
+
this.#seen.set(key, n + 1);
|
|
43
|
+
if (matches.length > 0 && (!this.recording || n < matches.length))
|
|
44
|
+
return toResponse(matches[Math.min(n, matches.length - 1)]);
|
|
45
|
+
if (!this.recording)
|
|
46
|
+
return undefined;
|
|
47
|
+
const entry = { request, response: await this.#forward(call) };
|
|
48
|
+
this.#entries.push(entry);
|
|
49
|
+
this.#added.push(entry);
|
|
50
|
+
return toResponse(entry);
|
|
51
|
+
}
|
|
52
|
+
/** Why a call went unanswered, for the stub's 501 reply. */
|
|
53
|
+
hint() {
|
|
54
|
+
return `no recording in ${this.file} either; run with SLICETEST_RECORD=${this.name} to record it from ${this.upstream}`;
|
|
55
|
+
}
|
|
56
|
+
/** Start of a scenario: identical requests replay from their first recording again. */
|
|
57
|
+
reset() {
|
|
58
|
+
this.#seen.clear();
|
|
59
|
+
}
|
|
60
|
+
/** Recordings made by this worker, to be merged into the file when the run ends. */
|
|
61
|
+
added() {
|
|
62
|
+
return this.#added;
|
|
63
|
+
}
|
|
64
|
+
async #forward(call) {
|
|
65
|
+
const url = new URL(this.upstream);
|
|
66
|
+
// Keep a path prefix on the upstream (e.g. https://api.example.com/v2) in front of the call's path.
|
|
67
|
+
url.pathname = url.pathname.replace(/\/$/, "") + call.path;
|
|
68
|
+
url.search = call.query.toString();
|
|
69
|
+
const headers = new Headers();
|
|
70
|
+
for (const [k, v] of Object.entries(call.headers)) {
|
|
71
|
+
if (v !== undefined && !HOP_HEADERS.includes(k))
|
|
72
|
+
headers.set(k, Array.isArray(v) ? v.join(", ") : v);
|
|
73
|
+
}
|
|
74
|
+
let res;
|
|
75
|
+
try {
|
|
76
|
+
res = await fetch(url, { method: call.method, headers, body: ["GET", "HEAD"].includes(call.method) ? undefined : call.body });
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
throw new Error(`slicetest: recording stub ${this.name}: ${call.method} ${url} failed: ${e.message}`);
|
|
80
|
+
}
|
|
81
|
+
const text = await res.text();
|
|
82
|
+
const kept = Object.fromEntries(KEPT_HEADERS.flatMap((h) => (res.headers.has(h) ? [[h, res.headers.get(h)]] : [])));
|
|
83
|
+
const json = parse(text);
|
|
84
|
+
return {
|
|
85
|
+
status: res.status,
|
|
86
|
+
...(Object.keys(kept).length ? { headers: kept } : {}),
|
|
87
|
+
...(json !== undefined ? { json } : text ? { body: text } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function requestOf(call) {
|
|
92
|
+
const query = Object.fromEntries([...call.query.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
93
|
+
return {
|
|
94
|
+
method: call.method,
|
|
95
|
+
path: call.path,
|
|
96
|
+
...(Object.keys(query).length ? { query } : {}),
|
|
97
|
+
...(call.json !== undefined ? { json: call.json } : call.body ? { body: call.body } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function keyOf(r) {
|
|
101
|
+
return JSON.stringify([r.method.toUpperCase(), r.path, sortKeys(r.query ?? {}), sortKeys(r.json ?? null), r.body ?? ""]);
|
|
102
|
+
}
|
|
103
|
+
function sortKeys(v) {
|
|
104
|
+
if (Array.isArray(v))
|
|
105
|
+
return v.map(sortKeys);
|
|
106
|
+
if (v && typeof v === "object")
|
|
107
|
+
return Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)).map(([k, x]) => [k, sortKeys(x)]));
|
|
108
|
+
return v;
|
|
109
|
+
}
|
|
110
|
+
function toResponse(e) {
|
|
111
|
+
return { status: e.response.status, headers: e.response.headers, body: e.response.json !== undefined ? e.response.json : (e.response.body ?? "") };
|
|
112
|
+
}
|
|
113
|
+
function parse(text) {
|
|
114
|
+
if (!text)
|
|
115
|
+
return undefined;
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(text);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function readRecordings(file) {
|
|
124
|
+
let text;
|
|
125
|
+
try {
|
|
126
|
+
text = await readFile(file, "utf8");
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
const doc = YAML.parse(text);
|
|
132
|
+
if (doc == null)
|
|
133
|
+
return [];
|
|
134
|
+
if (!Array.isArray(doc) || !doc.every((e) => e?.request?.method && e?.request?.path && typeof e?.response?.status === "number")) {
|
|
135
|
+
throw new Error(`slicetest: ${file} is not a recordings file: expected a list of { request: { method, path }, response: { status } }`);
|
|
136
|
+
}
|
|
137
|
+
return doc;
|
|
138
|
+
}
|
|
139
|
+
/** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
|
|
140
|
+
export async function mergeRecordings(file, upstream, added) {
|
|
141
|
+
const entries = await readRecordings(file);
|
|
142
|
+
for (const e of added)
|
|
143
|
+
if (!entries.some((x) => isDeepStrictEqual(x, e)))
|
|
144
|
+
entries.push(e);
|
|
145
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
146
|
+
const header = `# Recorded by slicetest from ${upstream}. Review before committing: request bodies are stored as sent.\n`;
|
|
147
|
+
await writeFile(file, header + YAML.stringify(entries, { lineWidth: 0 }));
|
|
148
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { App } from "./app.js";
|
|
2
2
|
import type { ResolvedOptions } from "./config.js";
|
|
3
|
+
import { Dependency } from "./containers.js";
|
|
3
4
|
import { Db } from "./db.js";
|
|
4
5
|
import { HttpClient } from "./http.js";
|
|
5
6
|
import { Stub } from "./stub.js";
|
|
7
|
+
import { type MaskOptions, type Trace } from "./trace.js";
|
|
6
8
|
export interface ScenarioContext {
|
|
7
9
|
http: HttpClient;
|
|
8
10
|
db: Db;
|
|
@@ -10,6 +12,13 @@ export interface ScenarioContext {
|
|
|
10
12
|
app: App;
|
|
11
13
|
/** A process from the `services` option: its `url`, `logs()` and `waitForLog()`. */
|
|
12
14
|
service: (name: string) => App;
|
|
15
|
+
/** A container from the `containers` option: its `host`, `port`, `address` and `exec()`. */
|
|
16
|
+
container: (name: string) => Dependency;
|
|
17
|
+
/**
|
|
18
|
+
* What the scenario did so far: requests to the app, calls to stubs and database changes,
|
|
19
|
+
* with timestamps and UUIDs masked. `expect(await trace()).toMatchSnapshot()`.
|
|
20
|
+
*/
|
|
21
|
+
trace: (opts?: MaskOptions) => Promise<Trace>;
|
|
13
22
|
}
|
|
14
23
|
/** Everything one test file needs: its own database, stub servers and app process. */
|
|
15
24
|
export declare class Runtime {
|
|
@@ -22,6 +31,9 @@ export declare class Runtime {
|
|
|
22
31
|
private readonly vars;
|
|
23
32
|
private readonly specs;
|
|
24
33
|
private readonly coverageDir?;
|
|
34
|
+
private readonly recorders;
|
|
35
|
+
private readonly recordDir?;
|
|
36
|
+
readonly containers: Map<string, Dependency>;
|
|
25
37
|
private constructor();
|
|
26
38
|
get http(): HttpClient;
|
|
27
39
|
static start(opts: ResolvedOptions, shared: {
|
|
@@ -29,6 +41,7 @@ export declare class Runtime {
|
|
|
29
41
|
template: string;
|
|
30
42
|
prefix: string;
|
|
31
43
|
coverageDir?: string;
|
|
44
|
+
recordDir?: string;
|
|
32
45
|
}): Promise<Runtime>;
|
|
33
46
|
context(): ScenarioContext;
|
|
34
47
|
beforeScenario(): Promise<void>;
|
package/dist/runtime.js
CHANGED
|
@@ -2,11 +2,14 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { App } from "./app.js";
|
|
5
|
+
import { Dependency } from "./containers.js";
|
|
5
6
|
import { Db, formatChanges } from "./db.js";
|
|
6
7
|
import { engineFor } from "./drivers/index.js";
|
|
7
8
|
import { formatHistory, HttpClient } from "./http.js";
|
|
8
9
|
import { OpenApiSpec } from "./openapi.js";
|
|
10
|
+
import { Recorder } from "./recording.js";
|
|
9
11
|
import { Stub } from "./stub.js";
|
|
12
|
+
import { buildTrace, mask } from "./trace.js";
|
|
10
13
|
/** Everything one test file needs: its own database, stub servers and app process. */
|
|
11
14
|
export class Runtime {
|
|
12
15
|
app;
|
|
@@ -17,12 +20,15 @@ export class Runtime {
|
|
|
17
20
|
vars;
|
|
18
21
|
specs;
|
|
19
22
|
coverageDir;
|
|
23
|
+
recorders;
|
|
24
|
+
recordDir;
|
|
25
|
+
containers;
|
|
20
26
|
#http;
|
|
21
27
|
/** Responses from the app that don't match its OpenAPI spec, this scenario. */
|
|
22
28
|
#contract = [];
|
|
23
29
|
/** Documented responses seen in this file, for the run's coverage report. */
|
|
24
30
|
#covered = new Set();
|
|
25
|
-
constructor(app, services, db, stubs, opts, vars, specs, coverageDir) {
|
|
31
|
+
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map()) {
|
|
26
32
|
this.app = app;
|
|
27
33
|
this.services = services;
|
|
28
34
|
this.db = db;
|
|
@@ -31,6 +37,9 @@ export class Runtime {
|
|
|
31
37
|
this.vars = vars;
|
|
32
38
|
this.specs = specs;
|
|
33
39
|
this.coverageDir = coverageDir;
|
|
40
|
+
this.recorders = recorders;
|
|
41
|
+
this.recordDir = recordDir;
|
|
42
|
+
this.containers = containers;
|
|
34
43
|
this.#http = this.#client();
|
|
35
44
|
}
|
|
36
45
|
#client() {
|
|
@@ -56,9 +65,11 @@ export class Runtime {
|
|
|
56
65
|
return this.#http;
|
|
57
66
|
}
|
|
58
67
|
static async start(opts, shared) {
|
|
59
|
-
const engine = engineFor(opts);
|
|
68
|
+
const engine = await engineFor(opts);
|
|
60
69
|
const url = await ensureWorkerDatabase(engine, shared.adminUrl, shared.template, shared.prefix);
|
|
61
70
|
const stubs = new Map();
|
|
71
|
+
const recorders = new Map();
|
|
72
|
+
const containers = new Map();
|
|
62
73
|
const services = new Map();
|
|
63
74
|
let db;
|
|
64
75
|
try {
|
|
@@ -71,16 +82,31 @@ export class Runtime {
|
|
|
71
82
|
specs.stubs.set(name, await OpenApiSpec.load(path.resolve(opts.root, file), file));
|
|
72
83
|
for (const name of opts.stubs)
|
|
73
84
|
stubs.set(name, await Stub.start(name));
|
|
74
|
-
for (const name of opts.
|
|
75
|
-
|
|
76
|
-
|
|
85
|
+
for (const [name, r] of Object.entries(opts.recordings))
|
|
86
|
+
recorders.set(name, await Recorder.load(name, path.resolve(opts.root, r.file), r.upstream, r.record));
|
|
87
|
+
// A registered route wins, then a recording of the real service, then an answer made up from its spec.
|
|
88
|
+
for (const [name, stub] of stubs) {
|
|
89
|
+
const recorder = recorders.get(name);
|
|
90
|
+
const spec = opts.openapi.autoReply.includes(name) ? specs.stubs.get(name) : undefined;
|
|
91
|
+
if (!recorder && !spec)
|
|
92
|
+
continue;
|
|
93
|
+
stub.fallback(async (call) => (await recorder?.answer(call)) ?? spec?.exampleResponse(call.method, call.path), recorder?.hint());
|
|
77
94
|
}
|
|
78
95
|
db = await Db.connect(await engine.driver(url), url, {
|
|
79
96
|
schemas: opts.db.schemas,
|
|
80
97
|
keep: opts.db.keep,
|
|
81
98
|
seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
|
|
82
99
|
});
|
|
100
|
+
const started = await Promise.allSettled(Object.entries(opts.containers).map(async ([name, c]) => containers.set(name, await Dependency.start(name, c))));
|
|
101
|
+
const failed = started.find((r) => r.status === "rejected");
|
|
102
|
+
if (failed)
|
|
103
|
+
throw failed.reason;
|
|
83
104
|
const vars = { "db.url": url };
|
|
105
|
+
for (const [name, c] of containers) {
|
|
106
|
+
vars[`container.${name}`] = c.address;
|
|
107
|
+
vars[`container.${name}.host`] = c.host;
|
|
108
|
+
vars[`container.${name}.port`] = String(c.port);
|
|
109
|
+
}
|
|
84
110
|
for (const [name, stub] of stubs)
|
|
85
111
|
vars[`stub.${name}`] = stub.url;
|
|
86
112
|
for (const [name, service] of Object.entries(opts.services)) {
|
|
@@ -90,12 +116,13 @@ export class Runtime {
|
|
|
90
116
|
vars[`service.${name}.port`] = String(started.port);
|
|
91
117
|
}
|
|
92
118
|
const app = await App.start(opts.app, opts.root, vars);
|
|
93
|
-
return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir);
|
|
119
|
+
return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers);
|
|
94
120
|
}
|
|
95
121
|
catch (e) {
|
|
96
122
|
await Promise.all([...services.values()].map((s) => s.stop()));
|
|
97
123
|
await db?.close();
|
|
98
124
|
await Promise.all([...stubs.values()].map((s) => s.close()));
|
|
125
|
+
await Promise.allSettled([...containers.values()].map((c) => c.stop()));
|
|
99
126
|
throw e;
|
|
100
127
|
}
|
|
101
128
|
}
|
|
@@ -118,6 +145,13 @@ export class Runtime {
|
|
|
118
145
|
}
|
|
119
146
|
return service;
|
|
120
147
|
},
|
|
148
|
+
container: (name) => {
|
|
149
|
+
const c = this.containers.get(name);
|
|
150
|
+
if (!c)
|
|
151
|
+
throw new Error(`slicetest: unknown container "${name}". Declared containers: ${[...this.containers.keys()].join(", ") || "(none)"}`);
|
|
152
|
+
return c;
|
|
153
|
+
},
|
|
154
|
+
trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart()), opts),
|
|
121
155
|
};
|
|
122
156
|
}
|
|
123
157
|
/** The app and every service, for checks that apply to all of them. */
|
|
@@ -141,6 +175,9 @@ export class Runtime {
|
|
|
141
175
|
await this.db.reset();
|
|
142
176
|
for (const stub of this.stubs.values())
|
|
143
177
|
stub.reset();
|
|
178
|
+
for (const recorder of this.recorders.values())
|
|
179
|
+
recorder.reset();
|
|
180
|
+
await Promise.all([...this.containers.values()].map((c) => c.reset()));
|
|
144
181
|
this.http.reset();
|
|
145
182
|
for (const p of this.#processes())
|
|
146
183
|
p.beginScenario();
|
|
@@ -195,6 +232,7 @@ export class Runtime {
|
|
|
195
232
|
return [
|
|
196
233
|
...calls.map((c) => ` ${s.name}: ${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`),
|
|
197
234
|
` registered on ${s.name}: ${routes.length ? routes.join(", ") : "(none)"}`,
|
|
235
|
+
...(this.recorders.has(s.name) ? [` ${this.recorders.get(s.name).hint()}`] : []),
|
|
198
236
|
];
|
|
199
237
|
});
|
|
200
238
|
}
|
|
@@ -233,11 +271,17 @@ export class Runtime {
|
|
|
233
271
|
if (this.coverageDir && this.#covered.size > 0) {
|
|
234
272
|
await writeFile(path.join(this.coverageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify([...this.#covered])).catch(() => { });
|
|
235
273
|
}
|
|
274
|
+
for (const [name, recorder] of this.recorders) {
|
|
275
|
+
if (this.recordDir && recorder.added().length > 0) {
|
|
276
|
+
await writeFile(path.join(this.recordDir, `${name}.${process.pid}-${randomUUID()}.json`), JSON.stringify(recorder.added())).catch(() => { });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
236
279
|
const results = await Promise.allSettled([
|
|
237
280
|
this.app.stop(),
|
|
238
281
|
...[...this.services.values()].map((s) => s.stop()),
|
|
239
282
|
this.db.close(),
|
|
240
283
|
...[...this.stubs.values()].map((s) => s.close()),
|
|
284
|
+
...[...this.containers.values()].map((c) => c.stop()),
|
|
241
285
|
]);
|
|
242
286
|
const failed = results.find((r) => r.status === "rejected");
|
|
243
287
|
if (failed)
|
package/dist/stub.d.ts
CHANGED
|
@@ -81,7 +81,7 @@ export declare class Stub {
|
|
|
81
81
|
* Return undefined to leave a call unanswered (it then fails the scenario as usual).
|
|
82
82
|
* Kept across scenarios, unlike routes.
|
|
83
83
|
*/
|
|
84
|
-
fallback(respond: ((call: RecordedCall) => StubResponse | undefined) | undefined): this;
|
|
84
|
+
fallback(respond: ((call: RecordedCall) => StubResponse | undefined | Promise<StubResponse | undefined>) | undefined, hint?: string): this;
|
|
85
85
|
close(): Promise<void>;
|
|
86
86
|
}
|
|
87
87
|
/** `expected` is contained in `actual`: objects compare key by key, arrays element-wise. */
|
package/dist/stub.js
CHANGED
|
@@ -10,6 +10,8 @@ export class Stub {
|
|
|
10
10
|
#routes = [];
|
|
11
11
|
#calls = [];
|
|
12
12
|
#fallback;
|
|
13
|
+
/** Appended to the 501 answer for a call nothing could answer. */
|
|
14
|
+
#hint;
|
|
13
15
|
url = "";
|
|
14
16
|
constructor(name) {
|
|
15
17
|
this.name = name;
|
|
@@ -86,8 +88,9 @@ export class Stub {
|
|
|
86
88
|
* Return undefined to leave a call unanswered (it then fails the scenario as usual).
|
|
87
89
|
* Kept across scenarios, unlike routes.
|
|
88
90
|
*/
|
|
89
|
-
fallback(respond) {
|
|
91
|
+
fallback(respond, hint) {
|
|
90
92
|
this.#fallback = respond;
|
|
93
|
+
this.#hint = hint;
|
|
91
94
|
return this;
|
|
92
95
|
}
|
|
93
96
|
async close() {
|
|
@@ -125,9 +128,17 @@ export class Stub {
|
|
|
125
128
|
break;
|
|
126
129
|
}
|
|
127
130
|
if (!route) {
|
|
128
|
-
|
|
131
|
+
let out;
|
|
132
|
+
try {
|
|
133
|
+
out = await this.#fallback?.(call);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
res.writeHead(502, { "content-type": "text/plain" }).end(e.message);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
129
139
|
if (!out) {
|
|
130
|
-
|
|
140
|
+
const hint = this.#hint ? ` (${this.#hint})` : "";
|
|
141
|
+
res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${call.method} ${call.path}${hint}`);
|
|
131
142
|
return;
|
|
132
143
|
}
|
|
133
144
|
call.matched = true;
|
package/dist/trace.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Changes } from "./db.js";
|
|
2
|
+
import type { HttpResponse } from "./http.js";
|
|
3
|
+
import type { Stub } from "./stub.js";
|
|
4
|
+
/**
|
|
5
|
+
* Everything one scenario did at the three boundaries: the requests it made to
|
|
6
|
+
* the app, the calls the app made to stubs, and the rows it changed. Meant for
|
|
7
|
+
* `expect(await trace()).toMatchSnapshot()`: resets make ids deterministic,
|
|
8
|
+
* and `mask` replaces the values that aren't (timestamps, UUIDs).
|
|
9
|
+
*/
|
|
10
|
+
export interface Trace {
|
|
11
|
+
http: {
|
|
12
|
+
request: string;
|
|
13
|
+
status: number;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
}[];
|
|
16
|
+
stubs: Record<string, {
|
|
17
|
+
request: string;
|
|
18
|
+
body?: unknown;
|
|
19
|
+
status?: number;
|
|
20
|
+
}[]>;
|
|
21
|
+
db: Changes;
|
|
22
|
+
}
|
|
23
|
+
export interface MaskOptions {
|
|
24
|
+
/** Keys whose values are always replaced, e.g. `["token", "password_hash"]`. Matched at any depth. */
|
|
25
|
+
keys?: string[];
|
|
26
|
+
/** Also mask strings matching these patterns, e.g. `[/^tok_\w+$/]`. */
|
|
27
|
+
patterns?: RegExp[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A copy of `value` with the parts that change from run to run replaced by
|
|
31
|
+
* placeholders: dates (`Date` objects and ISO strings) by `[date]`, UUIDs by
|
|
32
|
+
* `[uuid]`, and the values of `keys` / strings matching `patterns` by `[masked]`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mask<T>(value: T, opts?: MaskOptions): T;
|
|
35
|
+
export declare function buildTrace(history: readonly HttpResponse[], stubs: Iterable<Stub>, db: Changes): Trace;
|
package/dist/trace.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
2
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
|
+
/**
|
|
4
|
+
* A copy of `value` with the parts that change from run to run replaced by
|
|
5
|
+
* placeholders: dates (`Date` objects and ISO strings) by `[date]`, UUIDs by
|
|
6
|
+
* `[uuid]`, and the values of `keys` / strings matching `patterns` by `[masked]`.
|
|
7
|
+
*/
|
|
8
|
+
export function mask(value, opts = {}) {
|
|
9
|
+
const keys = new Set(opts.keys ?? []);
|
|
10
|
+
const walk = (v) => {
|
|
11
|
+
if (v instanceof Date)
|
|
12
|
+
return "[date]";
|
|
13
|
+
if (typeof v === "bigint")
|
|
14
|
+
return v.toString();
|
|
15
|
+
if (typeof v === "string") {
|
|
16
|
+
if (UUID.test(v))
|
|
17
|
+
return "[uuid]";
|
|
18
|
+
if (ISO_DATE.test(v))
|
|
19
|
+
return "[date]";
|
|
20
|
+
if (opts.patterns?.some((p) => ((p.lastIndex = 0), p.test(v))))
|
|
21
|
+
return "[masked]";
|
|
22
|
+
return v;
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(v))
|
|
25
|
+
return v.map(walk);
|
|
26
|
+
if (v && typeof v === "object") {
|
|
27
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, keys.has(k) && x != null ? "[masked]" : walk(x)]));
|
|
28
|
+
}
|
|
29
|
+
return v;
|
|
30
|
+
};
|
|
31
|
+
return walk(value);
|
|
32
|
+
}
|
|
33
|
+
export function buildTrace(history, stubs, db) {
|
|
34
|
+
const trace = {
|
|
35
|
+
http: history.map((r) => {
|
|
36
|
+
const url = new URL(r.url, "http://app");
|
|
37
|
+
const body = r.json !== undefined ? r.json : r.text || undefined;
|
|
38
|
+
return { request: `${r.method} ${url.pathname}${url.search}`, status: r.status, ...(body !== undefined ? { body } : {}) };
|
|
39
|
+
}),
|
|
40
|
+
stubs: {},
|
|
41
|
+
db,
|
|
42
|
+
};
|
|
43
|
+
for (const stub of stubs) {
|
|
44
|
+
const calls = stub.calls();
|
|
45
|
+
if (calls.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
trace.stubs[stub.name] = calls.map((c) => {
|
|
48
|
+
const body = c.json !== undefined ? c.json : c.body || undefined;
|
|
49
|
+
return {
|
|
50
|
+
request: `${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`,
|
|
51
|
+
...(body !== undefined ? { body } : {}),
|
|
52
|
+
...(c.response ? { status: c.response.status } : {}),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return trace;
|
|
57
|
+
}
|
package/dist/yaml-runtime.js
CHANGED
|
@@ -45,6 +45,8 @@ function describeStep(step) {
|
|
|
45
45
|
return "changes";
|
|
46
46
|
if ("checkpoint" in step)
|
|
47
47
|
return "checkpoint";
|
|
48
|
+
if ("snapshot" in step)
|
|
49
|
+
return "snapshot";
|
|
48
50
|
if ("log" in step)
|
|
49
51
|
return `log ${step.from ? `from ${step.from} ` : ""}/${step.log}/`;
|
|
50
52
|
return "sql";
|
|
@@ -73,6 +75,10 @@ async function runStep(step, ctx, vars) {
|
|
|
73
75
|
await ctx.db.checkpoint();
|
|
74
76
|
return;
|
|
75
77
|
}
|
|
78
|
+
if ("snapshot" in step) {
|
|
79
|
+
expect(await ctx.trace({ keys: step.mask })).toMatchSnapshot();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
76
82
|
if ("log" in step) {
|
|
77
83
|
const target = step.from ? ctx.service(step.from) : ctx.app;
|
|
78
84
|
await target.waitForLog(new RegExp(interpolate(step.log, vars)), step.within ?? 5000);
|
package/dist/yaml.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface YamlScenario {
|
|
|
19
19
|
timeout?: number;
|
|
20
20
|
steps: Step[];
|
|
21
21
|
}
|
|
22
|
-
export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep) & {
|
|
22
|
+
export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep) & {
|
|
23
23
|
line: number;
|
|
24
24
|
name?: string;
|
|
25
25
|
};
|
|
@@ -123,6 +123,12 @@ export interface LogStep {
|
|
|
123
123
|
export interface CheckpointStep {
|
|
124
124
|
checkpoint: true;
|
|
125
125
|
}
|
|
126
|
+
/** Compare the scenario's trace (requests, stub calls, database changes) with its stored snapshot. */
|
|
127
|
+
export interface SnapshotStep {
|
|
128
|
+
snapshot: true;
|
|
129
|
+
/** Keys whose values are masked in addition to dates and UUIDs. */
|
|
130
|
+
mask?: string[];
|
|
131
|
+
}
|
|
126
132
|
export declare class YamlScenarioError extends Error {
|
|
127
133
|
}
|
|
128
134
|
export declare function parseScenarioFile(text: string, file: string): YamlFile;
|
package/dist/yaml.js
CHANGED
|
@@ -9,6 +9,7 @@ const KINDS = {
|
|
|
9
9
|
changes: ["within"],
|
|
10
10
|
log: ["from", "within"],
|
|
11
11
|
checkpoint: [],
|
|
12
|
+
snapshot: ["mask"],
|
|
12
13
|
};
|
|
13
14
|
const EXPECT_KEYS = {
|
|
14
15
|
request: ["status", "headers", "json", "text"],
|
|
@@ -111,6 +112,12 @@ function parseStep(node, fail, lineOf) {
|
|
|
111
112
|
if (raw.checkpoint !== true)
|
|
112
113
|
fail(at(kind), "use `checkpoint: true`");
|
|
113
114
|
}
|
|
115
|
+
else if (kind === "snapshot") {
|
|
116
|
+
if (raw.snapshot !== true)
|
|
117
|
+
fail(at(kind), "use `snapshot: true`");
|
|
118
|
+
if (raw.mask !== undefined && !(Array.isArray(raw.mask) && raw.mask.every((k) => typeof k === "string")))
|
|
119
|
+
fail(at("mask"), "`mask:` must be a list of keys, e.g. [token]");
|
|
120
|
+
}
|
|
114
121
|
else if (typeof raw[kind] !== "string" || !raw[kind])
|
|
115
122
|
fail(at(kind), `\`${kind}:\` must be a non-empty string`);
|
|
116
123
|
const keysOf = (key, allowedKeys) => {
|