slicetest 0.3.0 → 0.5.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 +211 -7
- package/dist/auth.d.ts +45 -0
- package/dist/auth.js +128 -0
- package/dist/ci.d.ts +29 -0
- package/dist/ci.js +57 -0
- package/dist/cli.js +31 -3
- package/dist/config.d.ts +25 -2
- package/dist/config.js +25 -4
- package/dist/db.d.ts +26 -0
- package/dist/db.js +50 -0
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +190 -0
- package/dist/drivers/driver.d.ts +29 -0
- package/dist/drivers/index.js +2 -0
- package/dist/drivers/mysql.d.ts +2 -1
- package/dist/drivers/mysql.js +38 -0
- package/dist/drivers/postgres.d.ts +2 -1
- package/dist/drivers/postgres.js +28 -0
- package/dist/drivers/sqlite.d.ts +24 -0
- package/dist/drivers/sqlite.js +255 -0
- package/dist/factory.d.ts +21 -0
- package/dist/factory.js +129 -0
- package/dist/gen.d.ts +1 -0
- package/dist/gen.js +9 -4
- package/dist/global-setup.js +24 -3
- package/dist/http.d.ts +13 -0
- package/dist/http.js +24 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/init.js +123 -5
- package/dist/mail.d.ts +58 -0
- package/dist/mail.js +299 -0
- package/dist/matchers.d.ts +4 -0
- package/dist/matchers.js +33 -0
- package/dist/openapi.d.ts +9 -0
- package/dist/openapi.js +29 -1
- package/dist/provided.d.ts +1 -0
- package/dist/query-log.d.ts +49 -0
- package/dist/query-log.js +261 -0
- package/dist/record-cli.d.ts +6 -0
- package/dist/record-cli.js +93 -0
- package/dist/record-session.d.ts +1 -0
- package/dist/record-session.js +10 -0
- package/dist/record.d.ts +46 -0
- package/dist/record.js +201 -0
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.js +62 -3
- package/dist/stub.d.ts +32 -0
- package/dist/stub.js +87 -0
- package/dist/trace.d.ts +9 -1
- package/dist/trace.js +3 -1
- package/dist/vitest.js +5 -2
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +52 -0
- package/dist/yaml-runtime.d.ts +1 -0
- package/dist/yaml-runtime.js +92 -15
- package/dist/yaml.d.ts +59 -1
- package/dist/yaml.js +88 -5
- package/package.json +11 -4
- package/schema/scenario.schema.json +329 -1
package/dist/openapi.js
CHANGED
|
@@ -157,9 +157,27 @@ export class OpenApiSpec {
|
|
|
157
157
|
json: json ?? undefined,
|
|
158
158
|
responses,
|
|
159
159
|
createdFields: okSchema?.properties ? Object.keys(okSchema.properties) : [],
|
|
160
|
+
bearer: this.#bearer(op.security ?? this.doc.security),
|
|
160
161
|
};
|
|
161
162
|
});
|
|
162
163
|
}
|
|
164
|
+
#bearer(security) {
|
|
165
|
+
if (!Array.isArray(security) || security.length === 0)
|
|
166
|
+
return undefined;
|
|
167
|
+
// An empty requirement makes credentials optional.
|
|
168
|
+
if (security.some((req) => Object.keys(req ?? {}).length === 0))
|
|
169
|
+
return undefined;
|
|
170
|
+
const schemes = this.doc.components?.securitySchemes ?? {};
|
|
171
|
+
for (const req of security) {
|
|
172
|
+
for (const [name, scopes] of Object.entries(req)) {
|
|
173
|
+
const s = this.#deref(schemes[name]);
|
|
174
|
+
if (s && ((s.type === "http" && String(s.scheme).toLowerCase() === "bearer") || s.type === "oauth2" || s.type === "openIdConnect")) {
|
|
175
|
+
return { scopes: Array.isArray(scopes) ? scopes : [] };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
163
181
|
/** A value that satisfies `schema` (as far as a simple walk can): examples, defaults, enums, then types. */
|
|
164
182
|
#sample(schema, depth = 0) {
|
|
165
183
|
const s = this.#deref(schema);
|
|
@@ -306,7 +324,17 @@ export function formatCoverage(spec, hits) {
|
|
|
306
324
|
});
|
|
307
325
|
const covered = keys.filter((k) => hits.has(k)).length;
|
|
308
326
|
const percent = keys.length ? Math.round((covered / keys.length) * 100) : 100;
|
|
309
|
-
|
|
327
|
+
const markdown = [
|
|
328
|
+
`### slicetest: OpenAPI coverage ${percent}%`,
|
|
329
|
+
"",
|
|
330
|
+
`${covered} of ${keys.length} documented responses in \`${spec.file}\` were produced by a scenario.`,
|
|
331
|
+
"",
|
|
332
|
+
"| Operation | Responses |",
|
|
333
|
+
"|---|---|",
|
|
334
|
+
...[...byOp].map(([op, statuses]) => `| \`${op}\` | ${statuses.map((s) => `${hits.has(`${op} ${s}`) ? "✅" : "❌"} ${s}`).join(" ")} |`),
|
|
335
|
+
"",
|
|
336
|
+
].join("\n");
|
|
337
|
+
return { covered, total: keys.length, percent, markdown, text: `slicetest: OpenAPI coverage (${spec.file}): ${covered}/${keys.length} documented responses (${percent}%)\n${lines.join("\n")}` };
|
|
310
338
|
}
|
|
311
339
|
function isJson(media) {
|
|
312
340
|
return /[/+]json$/i.test(media) || media === "*/*";
|
package/dist/provided.d.ts
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface Query {
|
|
2
|
+
sql: string;
|
|
3
|
+
/** Milliseconds since the log started. */
|
|
4
|
+
at: number;
|
|
5
|
+
}
|
|
6
|
+
/** A list of queries, with helpers for spotting N+1 patterns. */
|
|
7
|
+
export declare class QueryList extends Array<Query> {
|
|
8
|
+
/** Statements grouped by shape (literals and parameters replaced by `?`), most frequent first. */
|
|
9
|
+
shapes(): {
|
|
10
|
+
sql: string;
|
|
11
|
+
count: number;
|
|
12
|
+
}[];
|
|
13
|
+
/** Without transaction control and session statements (`BEGIN`, `COMMIT`, `SAVEPOINT`, `SET`, …), which some drivers add around every query. */
|
|
14
|
+
withoutTransactions(): QueryList;
|
|
15
|
+
/** Shapes run at least `min` times (default 3): the usual signature of an N+1. */
|
|
16
|
+
repeated(min?: number): {
|
|
17
|
+
sql: string;
|
|
18
|
+
count: number;
|
|
19
|
+
}[];
|
|
20
|
+
}
|
|
21
|
+
export declare function queryList(queries: Query[]): QueryList;
|
|
22
|
+
/** `SELECT * FROM t WHERE id = 7 AND name = 'x'` → `SELECT * FROM t WHERE id = ? AND name = ?`. */
|
|
23
|
+
export declare function normalizeSql(sql: string): string;
|
|
24
|
+
type Engine = "postgres" | "mysql";
|
|
25
|
+
/**
|
|
26
|
+
* A TCP proxy between the app and its database that reads the wire protocol
|
|
27
|
+
* and records every statement the app runs, whatever its language or driver.
|
|
28
|
+
* Postgres: simple queries and extended-protocol executions. MySQL: COM_QUERY
|
|
29
|
+
* and prepared-statement executions. Connections that switch to TLS are
|
|
30
|
+
* forwarded but not read.
|
|
31
|
+
*/
|
|
32
|
+
export declare class QueryLog {
|
|
33
|
+
#private;
|
|
34
|
+
private readonly server;
|
|
35
|
+
readonly port: number;
|
|
36
|
+
private constructor();
|
|
37
|
+
static start(engine: Engine, upstream: {
|
|
38
|
+
host: string;
|
|
39
|
+
port: number;
|
|
40
|
+
}): Promise<QueryLog>;
|
|
41
|
+
/** `url` with its host and port pointing at the proxy. */
|
|
42
|
+
proxyUrl(url: string): string;
|
|
43
|
+
/** Queries since the last `reset()`, or since `mark` (an index from `mark()`). */
|
|
44
|
+
queries(since?: number): QueryList;
|
|
45
|
+
mark(): number;
|
|
46
|
+
reset(): void;
|
|
47
|
+
close(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
const TRANSACTION = /^\s*(BEGIN|START\s+TRANSACTION|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE|SET|SHOW|DISCARD|DEALLOCATE)\b/i;
|
|
3
|
+
/** A list of queries, with helpers for spotting N+1 patterns. */
|
|
4
|
+
export class QueryList extends Array {
|
|
5
|
+
/** Statements grouped by shape (literals and parameters replaced by `?`), most frequent first. */
|
|
6
|
+
shapes() {
|
|
7
|
+
const counts = new Map();
|
|
8
|
+
for (const q of this) {
|
|
9
|
+
const shape = normalizeSql(q.sql);
|
|
10
|
+
counts.set(shape, (counts.get(shape) ?? 0) + 1);
|
|
11
|
+
}
|
|
12
|
+
return [...counts].map(([sql, count]) => ({ sql, count })).sort((a, b) => b.count - a.count);
|
|
13
|
+
}
|
|
14
|
+
/** Without transaction control and session statements (`BEGIN`, `COMMIT`, `SAVEPOINT`, `SET`, …), which some drivers add around every query. */
|
|
15
|
+
withoutTransactions() {
|
|
16
|
+
return queryList(this.filter((q) => !TRANSACTION.test(q.sql)));
|
|
17
|
+
}
|
|
18
|
+
/** Shapes run at least `min` times (default 3): the usual signature of an N+1. */
|
|
19
|
+
repeated(min = 3) {
|
|
20
|
+
return this.shapes().filter((s) => s.count >= min);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function queryList(queries) {
|
|
24
|
+
const list = new QueryList();
|
|
25
|
+
list.push(...queries);
|
|
26
|
+
return list;
|
|
27
|
+
}
|
|
28
|
+
/** `SELECT * FROM t WHERE id = 7 AND name = 'x'` → `SELECT * FROM t WHERE id = ? AND name = ?`. */
|
|
29
|
+
export function normalizeSql(sql) {
|
|
30
|
+
return sql
|
|
31
|
+
.replace(/\s+/g, " ")
|
|
32
|
+
.trim()
|
|
33
|
+
.replace(/'(?:[^']|'')*'/g, "?")
|
|
34
|
+
.replace(/\$\d+/g, "?")
|
|
35
|
+
.replace(/(?<![\w"`.])-?\d+(\.\d+)?\b/g, "?")
|
|
36
|
+
.replace(/\(\s*\?(\s*,\s*\?)+\s*\)/g, "(?)");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A TCP proxy between the app and its database that reads the wire protocol
|
|
40
|
+
* and records every statement the app runs, whatever its language or driver.
|
|
41
|
+
* Postgres: simple queries and extended-protocol executions. MySQL: COM_QUERY
|
|
42
|
+
* and prepared-statement executions. Connections that switch to TLS are
|
|
43
|
+
* forwarded but not read.
|
|
44
|
+
*/
|
|
45
|
+
export class QueryLog {
|
|
46
|
+
server;
|
|
47
|
+
port;
|
|
48
|
+
#queries = [];
|
|
49
|
+
#started = performance.now();
|
|
50
|
+
#sockets = new Set();
|
|
51
|
+
constructor(server, port) {
|
|
52
|
+
this.server = server;
|
|
53
|
+
this.port = port;
|
|
54
|
+
}
|
|
55
|
+
static async start(engine, upstream) {
|
|
56
|
+
let log;
|
|
57
|
+
const server = net.createServer((client) => log.#connect(engine, client, upstream));
|
|
58
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
59
|
+
log = new QueryLog(server, server.address().port);
|
|
60
|
+
return log;
|
|
61
|
+
}
|
|
62
|
+
/** `url` with its host and port pointing at the proxy. */
|
|
63
|
+
proxyUrl(url) {
|
|
64
|
+
const u = new URL(url);
|
|
65
|
+
u.hostname = "127.0.0.1";
|
|
66
|
+
u.port = String(this.port);
|
|
67
|
+
return u.toString();
|
|
68
|
+
}
|
|
69
|
+
/** Queries since the last `reset()`, or since `mark` (an index from `mark()`). */
|
|
70
|
+
queries(since = 0) {
|
|
71
|
+
return queryList(this.#queries.slice(since));
|
|
72
|
+
}
|
|
73
|
+
mark() {
|
|
74
|
+
return this.#queries.length;
|
|
75
|
+
}
|
|
76
|
+
reset() {
|
|
77
|
+
this.#queries = [];
|
|
78
|
+
}
|
|
79
|
+
#record(sql) {
|
|
80
|
+
this.#queries.push({ sql, at: Math.round(performance.now() - this.#started) });
|
|
81
|
+
}
|
|
82
|
+
#connect(engine, client, upstream) {
|
|
83
|
+
const server = net.connect(upstream.port, upstream.host);
|
|
84
|
+
this.#sockets.add(client).add(server);
|
|
85
|
+
const close = () => {
|
|
86
|
+
client.destroy();
|
|
87
|
+
server.destroy();
|
|
88
|
+
this.#sockets.delete(client);
|
|
89
|
+
this.#sockets.delete(server);
|
|
90
|
+
};
|
|
91
|
+
client.on("error", close).on("close", close);
|
|
92
|
+
server.on("error", close).on("close", close);
|
|
93
|
+
const reader = engine === "postgres" ? new PostgresReader((sql) => this.#record(sql)) : new MysqlReader((sql) => this.#record(sql));
|
|
94
|
+
client.on("data", (chunk) => {
|
|
95
|
+
try {
|
|
96
|
+
reader.fromClient(chunk);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
reader.opaque = true;
|
|
100
|
+
}
|
|
101
|
+
server.write(chunk);
|
|
102
|
+
});
|
|
103
|
+
server.on("data", (chunk) => {
|
|
104
|
+
try {
|
|
105
|
+
reader.fromServer(chunk);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
reader.opaque = true;
|
|
109
|
+
}
|
|
110
|
+
client.write(chunk);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async close() {
|
|
114
|
+
for (const s of this.#sockets)
|
|
115
|
+
s.destroy();
|
|
116
|
+
await new Promise((resolve) => this.server.close(resolve));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const cstring = (buf, start) => {
|
|
120
|
+
const end = buf.indexOf(0, start);
|
|
121
|
+
return [buf.toString("utf8", start, end), end + 1];
|
|
122
|
+
};
|
|
123
|
+
class PostgresReader {
|
|
124
|
+
record;
|
|
125
|
+
opaque = false;
|
|
126
|
+
#buf = Buffer.alloc(0);
|
|
127
|
+
#startup = true;
|
|
128
|
+
#awaitingSsl = false;
|
|
129
|
+
#statements = new Map();
|
|
130
|
+
#portals = new Map();
|
|
131
|
+
constructor(record) {
|
|
132
|
+
this.record = record;
|
|
133
|
+
}
|
|
134
|
+
fromServer(chunk) {
|
|
135
|
+
if (!this.#awaitingSsl)
|
|
136
|
+
return;
|
|
137
|
+
this.#awaitingSsl = false;
|
|
138
|
+
// 'S' (TLS) or 'G' (GSSAPI encryption): the rest is encrypted.
|
|
139
|
+
if (chunk[0] === 0x53 || chunk[0] === 0x47)
|
|
140
|
+
this.opaque = true;
|
|
141
|
+
}
|
|
142
|
+
fromClient(chunk) {
|
|
143
|
+
if (this.opaque)
|
|
144
|
+
return;
|
|
145
|
+
this.#buf = Buffer.concat([this.#buf, chunk]);
|
|
146
|
+
for (;;) {
|
|
147
|
+
if (this.#startup) {
|
|
148
|
+
if (this.#buf.length < 8)
|
|
149
|
+
return;
|
|
150
|
+
const len = this.#buf.readInt32BE(0);
|
|
151
|
+
if (this.#buf.length < len)
|
|
152
|
+
return;
|
|
153
|
+
const code = this.#buf.readInt32BE(4);
|
|
154
|
+
this.#buf = this.#buf.subarray(len);
|
|
155
|
+
if (code === 80877103 || code === 80877104)
|
|
156
|
+
this.#awaitingSsl = true;
|
|
157
|
+
else if (code !== 80877102)
|
|
158
|
+
this.#startup = false;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (this.#buf.length < 5)
|
|
162
|
+
return;
|
|
163
|
+
const type = String.fromCharCode(this.#buf[0]);
|
|
164
|
+
const len = this.#buf.readInt32BE(1);
|
|
165
|
+
if (this.#buf.length < len + 1)
|
|
166
|
+
return;
|
|
167
|
+
const msg = this.#buf.subarray(5, len + 1);
|
|
168
|
+
this.#buf = this.#buf.subarray(len + 1);
|
|
169
|
+
this.#message(type, msg);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
#message(type, msg) {
|
|
173
|
+
if (type === "Q") {
|
|
174
|
+
this.record(cstring(msg, 0)[0]);
|
|
175
|
+
}
|
|
176
|
+
else if (type === "P") {
|
|
177
|
+
const [name, next] = cstring(msg, 0);
|
|
178
|
+
this.#statements.set(name, cstring(msg, next)[0]);
|
|
179
|
+
}
|
|
180
|
+
else if (type === "B") {
|
|
181
|
+
const [portal, next] = cstring(msg, 0);
|
|
182
|
+
this.#portals.set(portal, this.#statements.get(cstring(msg, next)[0]) ?? "");
|
|
183
|
+
}
|
|
184
|
+
else if (type === "E") {
|
|
185
|
+
const sql = this.#portals.get(cstring(msg, 0)[0]);
|
|
186
|
+
if (sql)
|
|
187
|
+
this.record(sql);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const CLIENT_QUERY_ATTRIBUTES = 1 << 27;
|
|
192
|
+
class MysqlReader {
|
|
193
|
+
record;
|
|
194
|
+
opaque = false;
|
|
195
|
+
#client = Buffer.alloc(0);
|
|
196
|
+
#server = Buffer.alloc(0);
|
|
197
|
+
#prepares = [];
|
|
198
|
+
#attributes = false;
|
|
199
|
+
#statements = new Map();
|
|
200
|
+
constructor(record) {
|
|
201
|
+
this.record = record;
|
|
202
|
+
}
|
|
203
|
+
#read(buf, each) {
|
|
204
|
+
while (buf.length >= 4) {
|
|
205
|
+
const len = buf.readUIntLE(0, 3);
|
|
206
|
+
if (buf.length < len + 4)
|
|
207
|
+
break;
|
|
208
|
+
each(buf[3], buf.subarray(4, len + 4));
|
|
209
|
+
buf = buf.subarray(len + 4);
|
|
210
|
+
}
|
|
211
|
+
return Buffer.from(buf);
|
|
212
|
+
}
|
|
213
|
+
fromClient(chunk) {
|
|
214
|
+
if (this.opaque)
|
|
215
|
+
return;
|
|
216
|
+
this.#client = this.#read(Buffer.concat([this.#client, chunk]), (seq, p) => {
|
|
217
|
+
// The handshake response asking for TLS is a short packet with CLIENT_SSL set.
|
|
218
|
+
if (seq === 1 && p.length === 32 && (p.readUInt32LE(0) & 0x800) !== 0)
|
|
219
|
+
this.opaque = true;
|
|
220
|
+
if (seq === 1 && p.length >= 4)
|
|
221
|
+
this.#attributes = (p.readUInt32LE(0) & CLIENT_QUERY_ATTRIBUTES) !== 0;
|
|
222
|
+
// Commands start a new sequence; handshake and auth packets don't.
|
|
223
|
+
if (seq !== 0 || p.length === 0)
|
|
224
|
+
return;
|
|
225
|
+
if (p[0] === 0x03)
|
|
226
|
+
this.record(this.#queryText(p));
|
|
227
|
+
else if (p[0] === 0x16)
|
|
228
|
+
this.#prepares.push(p.toString("utf8", 1));
|
|
229
|
+
else if (p[0] === 0x17 && p.length >= 5) {
|
|
230
|
+
const sql = this.#statements.get(p.readUInt32LE(1));
|
|
231
|
+
if (sql)
|
|
232
|
+
this.record(sql);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/** COM_QUERY text; with query attributes it follows a parameter count, a set count and the attributes. */
|
|
237
|
+
#queryText(p) {
|
|
238
|
+
if (!this.#attributes)
|
|
239
|
+
return p.toString("utf8", 1);
|
|
240
|
+
const count = p[1];
|
|
241
|
+
// Drivers send no attributes (count 0, one set); anything else is read past heuristically.
|
|
242
|
+
if (count === 0)
|
|
243
|
+
return p.toString("utf8", 3);
|
|
244
|
+
const text = p.toString("utf8", 3);
|
|
245
|
+
const start = text.search(/\b(SELECT|INSERT|UPDATE|DELETE|WITH|REPLACE|CALL|SET|BEGIN|COMMIT|ROLLBACK|START)\b/i);
|
|
246
|
+
return start >= 0 ? text.slice(start) : text;
|
|
247
|
+
}
|
|
248
|
+
fromServer(chunk) {
|
|
249
|
+
if (this.opaque || this.#prepares.length === 0) {
|
|
250
|
+
this.#server = Buffer.alloc(0);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.#server = this.#read(Buffer.concat([this.#server, chunk]), (seq, p) => {
|
|
254
|
+
if (seq !== 1 || this.#prepares.length === 0)
|
|
255
|
+
return;
|
|
256
|
+
const sql = this.#prepares.shift();
|
|
257
|
+
if (p[0] === 0x00 && p.length >= 5)
|
|
258
|
+
this.#statements.set(p.readUInt32LE(1), sql);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type SlicetestOptions } from "./config.js";
|
|
2
|
+
/** The main-process side of `npx slicetest record`: run the session, tell the user where to send requests, stop on Enter. */
|
|
3
|
+
export declare function record(configPath: string, options: SlicetestOptions, { out, port }: {
|
|
4
|
+
out?: string;
|
|
5
|
+
port?: number;
|
|
6
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { resolveOptions } from "./config.js";
|
|
8
|
+
import { SESSION_ENV } from "./record.js";
|
|
9
|
+
const ext = path.extname(fileURLToPath(import.meta.url));
|
|
10
|
+
const sessionFile = fileURLToPath(new URL(`./record-session${ext}`, import.meta.url));
|
|
11
|
+
/** The main-process side of `npx slicetest record`: run the session, tell the user where to send requests, stop on Enter. */
|
|
12
|
+
export async function record(configPath, options, { out, port = 0 }) {
|
|
13
|
+
const root = path.dirname(configPath);
|
|
14
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
15
|
+
const file = path.resolve(root, out ?? path.join("scenarios", `recorded-${stamp}.scenario.yaml`));
|
|
16
|
+
if (existsSync(file))
|
|
17
|
+
throw new Error(`slicetest record: ${path.relative(process.cwd(), file)} already exists. Pass --out <file>.`);
|
|
18
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
19
|
+
throw new Error(`slicetest record: --port must be a port number, got ${port}`);
|
|
20
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
21
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "slicetest-record-"));
|
|
22
|
+
const session = {
|
|
23
|
+
stateFile: path.join(dir, "state.json"),
|
|
24
|
+
stopFile: path.join(dir, "stop"),
|
|
25
|
+
out: file,
|
|
26
|
+
port,
|
|
27
|
+
stubs: resolveOptions(options, root).stubs,
|
|
28
|
+
};
|
|
29
|
+
process.env[SESSION_ENV] = JSON.stringify(session);
|
|
30
|
+
const stop = () => void writeFile(session.stopFile, "").catch(() => { });
|
|
31
|
+
process.once("SIGINT", stop);
|
|
32
|
+
const { startVitest } = await import("vitest/node");
|
|
33
|
+
const { slicetest } = await import("./vitest.js");
|
|
34
|
+
const announce = watchState(session.stateFile, (state) => {
|
|
35
|
+
process.stdout.write(`\nRecording. Use the app through ${state.url} (it forwards to ${state.app}).\n` +
|
|
36
|
+
"Everything it does is captured: responses, calls to stubs, database changes.\n" +
|
|
37
|
+
"Press Enter (or Ctrl+C) to finish and write the scenario.\n\n");
|
|
38
|
+
const rl = createInterface({ input: process.stdin });
|
|
39
|
+
rl.once("line", () => {
|
|
40
|
+
rl.close();
|
|
41
|
+
stop();
|
|
42
|
+
});
|
|
43
|
+
process.stdin.once("end", stop);
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
const vitest = await startVitest([], { config: false, root, include: [sessionFile.replace(/\\/g, "/")], exclude: [], watch: false, run: true, reporters: ["dot"] }, { plugins: [slicetest(options)] });
|
|
47
|
+
await vitest?.close();
|
|
48
|
+
const state = existsSync(session.stateFile) ? JSON.parse(await readFile(session.stateFile, "utf8")) : {};
|
|
49
|
+
if (!state.done || !state.summary) {
|
|
50
|
+
process.stderr.write("slicetest record: the session ended before anything was written (see the output above).\n");
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// The session is written; a failure after it (e.g. an unmatched stub call) is reported in the file's header.
|
|
55
|
+
process.exitCode = 0;
|
|
56
|
+
const s = state.summary;
|
|
57
|
+
process.stdout.write(`\nWrote ${path.relative(process.cwd(), file)}: ${s.requests} request(s), ${s.stubs} stub route(s), changes in ${s.tables.length ? s.tables.join(", ") : "no tables"}.\n` +
|
|
58
|
+
(s.skippedAssets ? `Left out ${s.skippedAssets} request(s) for static files.\n` : "") +
|
|
59
|
+
(s.unanswered.length ? `Calls no stub answered (see the file's header): ${s.unanswered.join(", ")}\n` : "") +
|
|
60
|
+
`Replay it with: npx slicetest ${path.relative(root, file).replace(/\\/g, "/")}\n`);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
announce.cancel();
|
|
64
|
+
process.off("SIGINT", stop);
|
|
65
|
+
// The Enter listener would otherwise keep the process alive.
|
|
66
|
+
process.stdin.destroy();
|
|
67
|
+
delete process.env[SESSION_ENV];
|
|
68
|
+
await rm(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function watchState(file, onReady) {
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
void (async () => {
|
|
74
|
+
while (!cancelled) {
|
|
75
|
+
if (existsSync(file)) {
|
|
76
|
+
try {
|
|
77
|
+
const state = JSON.parse(await readFile(file, "utf8"));
|
|
78
|
+
if (state.url)
|
|
79
|
+
return onReady(state);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Partly written; read it again.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
return {
|
|
89
|
+
cancel: () => {
|
|
90
|
+
cancelled = true;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test file `npx slicetest record` runs: one scenario that lasts until the
|
|
3
|
+
* user is done, so the session gets the same app, database and stubs as tests.
|
|
4
|
+
*/
|
|
5
|
+
import { SESSION_ENV, runSession } from "./record.js";
|
|
6
|
+
import { scenario } from "./scenario.js";
|
|
7
|
+
const session = JSON.parse(process.env[SESSION_ENV] ?? "null");
|
|
8
|
+
if (!session)
|
|
9
|
+
throw new Error("slicetest: this file is run by `npx slicetest record`");
|
|
10
|
+
scenario("slicetest record", (ctx) => runSession(ctx, session), 24 * 60 * 60 * 1000);
|
package/dist/record.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Changes } from "./db.js";
|
|
2
|
+
import type { ScenarioContext } from "./runtime.js";
|
|
3
|
+
import type { RecordedCall } from "./stub.js";
|
|
4
|
+
/**
|
|
5
|
+
* `npx slicetest record`: use the app for real (a browser, curl, a mobile
|
|
6
|
+
* client) through a proxy, and get the session back as a YAML scenario: the
|
|
7
|
+
* requests, the responses to expect, the stubbed services' replies and the
|
|
8
|
+
* database changes. The session runs as one long scenario, so the app, the
|
|
9
|
+
* database and the stubs are the same ones the tests use.
|
|
10
|
+
*/
|
|
11
|
+
/** Passed from the CLI to the session through the environment. */
|
|
12
|
+
export interface RecordSession {
|
|
13
|
+
/** Written by the session: `{ "url": … }` once the proxy listens, `{ "done": … }` at the end. */
|
|
14
|
+
stateFile: string;
|
|
15
|
+
/** Created by the CLI when the user is done. */
|
|
16
|
+
stopFile: string;
|
|
17
|
+
out: string;
|
|
18
|
+
port: number;
|
|
19
|
+
stubs: string[];
|
|
20
|
+
}
|
|
21
|
+
export declare const SESSION_ENV = "SLICETEST_RECORD_SESSION";
|
|
22
|
+
export interface Exchange {
|
|
23
|
+
method: string;
|
|
24
|
+
path: string;
|
|
25
|
+
contentType?: string;
|
|
26
|
+
body: string;
|
|
27
|
+
status: number;
|
|
28
|
+
responseType?: string;
|
|
29
|
+
response: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function runSession(ctx: ScenarioContext, session: RecordSession): Promise<void>;
|
|
32
|
+
export interface Summary {
|
|
33
|
+
requests: number;
|
|
34
|
+
skippedAssets: number;
|
|
35
|
+
stubs: number;
|
|
36
|
+
tables: string[];
|
|
37
|
+
unanswered: string[];
|
|
38
|
+
}
|
|
39
|
+
/** The recorded session as a YAML scenario, with a comment header of what to review. */
|
|
40
|
+
export declare function buildScenario(exchanges: Exchange[], calls: Record<string, RecordedCall[]>, changes: Changes, { name, now }?: {
|
|
41
|
+
name?: string | undefined;
|
|
42
|
+
now?: Date | undefined;
|
|
43
|
+
}): {
|
|
44
|
+
yaml: string;
|
|
45
|
+
summary: Summary;
|
|
46
|
+
};
|