@testsmith/api-spector 0.1.1 → 0.1.3
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/bin/cli.js +13 -4
- package/out/main/chunks/recorder-DFxJgn9c.js +241 -0
- package/out/main/chunks/{request-handler-9MdHOWVf.js → request-handler-CvqESn11.js} +7 -0
- package/out/main/index.js +35 -1
- package/out/main/record.js +109 -0
- package/out/main/runner.js +217 -8
- package/out/preload/index.js +15 -0
- package/out/renderer/assets/index-B46MEuwy.css +2 -0
- package/out/renderer/assets/{index-B7b-1sj6.js → index-BEMJvOJV.js} +562 -106
- package/out/renderer/index.html +2 -2
- package/package.json +3 -2
- package/out/renderer/assets/index-QI8AWMd3.css +0 -2
package/bin/cli.js
CHANGED
|
@@ -12,12 +12,14 @@ function printHelp() {
|
|
|
12
12
|
console.log('')
|
|
13
13
|
console.log(' Usage:')
|
|
14
14
|
console.log(' api-spector ui Launch the app')
|
|
15
|
-
console.log(' api-spector run
|
|
16
|
-
console.log(' api-spector mock
|
|
15
|
+
console.log(' api-spector run --workspace <path> Run tests from CLI')
|
|
16
|
+
console.log(' api-spector mock --workspace <path> Start mock servers from CLI')
|
|
17
|
+
console.log(' api-spector record --upstream <url> Record API traffic as mock stubs')
|
|
17
18
|
console.log('')
|
|
18
19
|
console.log(' Options:')
|
|
19
|
-
console.log(' api-spector run
|
|
20
|
-
console.log(' api-spector mock
|
|
20
|
+
console.log(' api-spector run --help Show run options')
|
|
21
|
+
console.log(' api-spector mock --help Show mock options')
|
|
22
|
+
console.log(' api-spector record --help Show record options')
|
|
21
23
|
console.log('')
|
|
22
24
|
console.log(' Environment:')
|
|
23
25
|
console.log(' ELECTRON_NO_SANDBOX=1 Disable Chromium sandbox')
|
|
@@ -50,6 +52,13 @@ if (cmd === '--help' || cmd === '-h') {
|
|
|
50
52
|
env: process.env,
|
|
51
53
|
})
|
|
52
54
|
proc.on('close', code => process.exit(code ?? 0))
|
|
55
|
+
} else if (cmd === 'record') {
|
|
56
|
+
const recordPath = path.join(__dirname, '..', 'out', 'main', 'record.js')
|
|
57
|
+
const proc = spawn(process.execPath, [recordPath, ...rest], {
|
|
58
|
+
stdio: 'inherit',
|
|
59
|
+
env: process.env,
|
|
60
|
+
})
|
|
61
|
+
proc.on('close', code => process.exit(code ?? 0))
|
|
53
62
|
} else {
|
|
54
63
|
console.error(`api Spector — unknown command: "${cmd}"`)
|
|
55
64
|
printHelp()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const http = require("http");
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const undici = require("undici");
|
|
5
|
+
const DEFAULT_MASK_HEADERS = /* @__PURE__ */ new Set([
|
|
6
|
+
"authorization",
|
|
7
|
+
"cookie",
|
|
8
|
+
"set-cookie",
|
|
9
|
+
"x-api-key",
|
|
10
|
+
"x-auth-token",
|
|
11
|
+
"x-access-token",
|
|
12
|
+
"proxy-authorization"
|
|
13
|
+
]);
|
|
14
|
+
const HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
15
|
+
"host",
|
|
16
|
+
"connection",
|
|
17
|
+
"keep-alive",
|
|
18
|
+
"proxy-connection",
|
|
19
|
+
"transfer-encoding",
|
|
20
|
+
"te",
|
|
21
|
+
"trailer",
|
|
22
|
+
"upgrade"
|
|
23
|
+
]);
|
|
24
|
+
const STRIP_FROM_MOCK_RESPONSE = /* @__PURE__ */ new Set([
|
|
25
|
+
"connection",
|
|
26
|
+
"keep-alive",
|
|
27
|
+
"transfer-encoding",
|
|
28
|
+
"content-encoding",
|
|
29
|
+
"content-length",
|
|
30
|
+
"strict-transport-security",
|
|
31
|
+
"alt-svc",
|
|
32
|
+
"cf-ray",
|
|
33
|
+
"cf-cache-status"
|
|
34
|
+
]);
|
|
35
|
+
let activeServer = null;
|
|
36
|
+
let activeConfig = null;
|
|
37
|
+
let activeEntries = [];
|
|
38
|
+
let activeStarted = "";
|
|
39
|
+
let activeMaskSet = /* @__PURE__ */ new Set();
|
|
40
|
+
let hitCallback = null;
|
|
41
|
+
function isRecorderRunning() {
|
|
42
|
+
return activeServer !== null;
|
|
43
|
+
}
|
|
44
|
+
function getRecorderEntries() {
|
|
45
|
+
return [...activeEntries];
|
|
46
|
+
}
|
|
47
|
+
function setRecorderHitCallback(cb) {
|
|
48
|
+
hitCallback = cb;
|
|
49
|
+
}
|
|
50
|
+
async function startRecorder(config) {
|
|
51
|
+
if (activeServer) throw new Error("Recorder already running");
|
|
52
|
+
const upstream = config.upstream.replace(/\/$/, "");
|
|
53
|
+
const maskSet = /* @__PURE__ */ new Set([
|
|
54
|
+
...DEFAULT_MASK_HEADERS,
|
|
55
|
+
...(config.maskHeaders ?? []).map((h) => h.toLowerCase())
|
|
56
|
+
]);
|
|
57
|
+
const ignoreSet = /* @__PURE__ */ new Set([
|
|
58
|
+
...HOP_BY_HOP,
|
|
59
|
+
...(config.ignoreHeaders ?? []).map((h) => h.toLowerCase())
|
|
60
|
+
]);
|
|
61
|
+
activeConfig = config;
|
|
62
|
+
activeEntries = [];
|
|
63
|
+
activeStarted = (/* @__PURE__ */ new Date()).toISOString();
|
|
64
|
+
activeMaskSet = maskSet;
|
|
65
|
+
const server = http.createServer(async (req, res) => {
|
|
66
|
+
const id = crypto.randomUUID();
|
|
67
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
68
|
+
const start = Date.now();
|
|
69
|
+
const rawUrl = req.url ?? "/";
|
|
70
|
+
const parsed = new URL(rawUrl, "http://localhost");
|
|
71
|
+
const path = parsed.pathname;
|
|
72
|
+
const query = parseQuery(parsed.search);
|
|
73
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
74
|
+
if (path === "/favicon.ico") {
|
|
75
|
+
res.writeHead(204);
|
|
76
|
+
res.end();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let reqBodyBuf;
|
|
80
|
+
try {
|
|
81
|
+
reqBodyBuf = await readBody(req);
|
|
82
|
+
} catch {
|
|
83
|
+
res.writeHead(400);
|
|
84
|
+
res.end("Bad request body");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const reqBodyStr = reqBodyBuf.length > 0 ? reqBodyBuf.toString("utf8") : null;
|
|
88
|
+
const forwardHeaders = new undici.Headers();
|
|
89
|
+
const recordedReqHeaders = {};
|
|
90
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
91
|
+
if (!v) continue;
|
|
92
|
+
const val = Array.isArray(v) ? v.join(", ") : v;
|
|
93
|
+
const lk = k.toLowerCase();
|
|
94
|
+
if (ignoreSet.has(lk)) continue;
|
|
95
|
+
forwardHeaders.set(k, val);
|
|
96
|
+
recordedReqHeaders[k] = maskSet.has(lk) ? "***" : val;
|
|
97
|
+
}
|
|
98
|
+
const upstreamUrl = new URL(upstream);
|
|
99
|
+
forwardHeaders.set("host", upstreamUrl.host);
|
|
100
|
+
const targetUrl = `${upstream}${rawUrl}`;
|
|
101
|
+
let upstreamResp;
|
|
102
|
+
let respBodyBuf;
|
|
103
|
+
let timedOut = false;
|
|
104
|
+
const timeoutMs = 3e4;
|
|
105
|
+
try {
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
controller.abort();
|
|
109
|
+
timedOut = true;
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
upstreamResp = await undici.fetch(targetUrl, {
|
|
112
|
+
method,
|
|
113
|
+
headers: forwardHeaders,
|
|
114
|
+
body: ["GET", "HEAD"].includes(method) ? void 0 : reqBodyBuf,
|
|
115
|
+
signal: controller.signal
|
|
116
|
+
});
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
respBodyBuf = Buffer.from(await upstreamResp.arrayBuffer());
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const msg = timedOut ? `Upstream timed out after ${timeoutMs}ms` : `Upstream error: ${err instanceof Error ? err.message : String(err)}`;
|
|
121
|
+
const entry2 = {
|
|
122
|
+
id,
|
|
123
|
+
timestamp,
|
|
124
|
+
durationMs: Date.now() - start,
|
|
125
|
+
request: { method, path, query, headers: recordedReqHeaders, body: reqBodyStr },
|
|
126
|
+
response: { status: 0, statusText: msg, headers: {}, body: null, binary: false, bodySize: 0 }
|
|
127
|
+
};
|
|
128
|
+
activeEntries.push(entry2);
|
|
129
|
+
hitCallback?.(entry2);
|
|
130
|
+
res.writeHead(504, { "content-type": "application/json" });
|
|
131
|
+
res.end(JSON.stringify({ error: msg }));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const durationMs = Date.now() - start;
|
|
135
|
+
const status = upstreamResp.status;
|
|
136
|
+
const statusText = upstreamResp.statusText;
|
|
137
|
+
const contentType = upstreamResp.headers.get("content-type") ?? "";
|
|
138
|
+
const binary = isBinary(contentType);
|
|
139
|
+
const recordedRespHeaders = {};
|
|
140
|
+
upstreamResp.headers.forEach((v, k) => {
|
|
141
|
+
if (!ignoreSet.has(k.toLowerCase())) recordedRespHeaders[k] = v;
|
|
142
|
+
});
|
|
143
|
+
const entry = {
|
|
144
|
+
id,
|
|
145
|
+
timestamp,
|
|
146
|
+
durationMs,
|
|
147
|
+
request: { method, path, query, headers: recordedReqHeaders, body: reqBodyStr },
|
|
148
|
+
response: {
|
|
149
|
+
status,
|
|
150
|
+
statusText,
|
|
151
|
+
headers: recordedRespHeaders,
|
|
152
|
+
body: binary ? `base64:${respBodyBuf.toString("base64")}` : respBodyBuf.toString("utf8"),
|
|
153
|
+
binary,
|
|
154
|
+
bodySize: respBodyBuf.length
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
activeEntries.push(entry);
|
|
158
|
+
hitCallback?.(entry);
|
|
159
|
+
const clientHeaders = {};
|
|
160
|
+
upstreamResp.headers.forEach((v, k) => {
|
|
161
|
+
const lk = k.toLowerCase();
|
|
162
|
+
if (!HOP_BY_HOP.has(lk) && lk !== "content-encoding") clientHeaders[k] = v;
|
|
163
|
+
});
|
|
164
|
+
clientHeaders["content-length"] = String(respBodyBuf.length);
|
|
165
|
+
res.writeHead(status, clientHeaders);
|
|
166
|
+
res.end(respBodyBuf);
|
|
167
|
+
});
|
|
168
|
+
await new Promise((ok, fail) => {
|
|
169
|
+
server.once("error", fail);
|
|
170
|
+
server.listen(config.port, "0.0.0.0", ok);
|
|
171
|
+
});
|
|
172
|
+
activeServer = server;
|
|
173
|
+
}
|
|
174
|
+
function stopRecorder() {
|
|
175
|
+
if (!activeServer) throw new Error("Recorder is not running");
|
|
176
|
+
activeServer.close();
|
|
177
|
+
activeServer = null;
|
|
178
|
+
const session = {
|
|
179
|
+
version: "1.0",
|
|
180
|
+
upstream: activeConfig?.upstream ?? "",
|
|
181
|
+
port: activeConfig?.port ?? 0,
|
|
182
|
+
startedAt: activeStarted,
|
|
183
|
+
maskedHeaders: [...activeMaskSet],
|
|
184
|
+
entries: activeEntries
|
|
185
|
+
};
|
|
186
|
+
activeConfig = null;
|
|
187
|
+
activeEntries = [];
|
|
188
|
+
return session;
|
|
189
|
+
}
|
|
190
|
+
function entriesToMockServer(entries, upstream, name, port) {
|
|
191
|
+
const seen = /* @__PURE__ */ new Map();
|
|
192
|
+
for (const e of entries) {
|
|
193
|
+
const key = `${e.request.method}:${e.request.path}`;
|
|
194
|
+
const existing = seen.get(key);
|
|
195
|
+
const isSuccess = e.response.status >= 200 && e.response.status < 300;
|
|
196
|
+
const existingIsSuccess = existing && existing.response.status >= 200 && existing.response.status < 300;
|
|
197
|
+
if (!existing || !existingIsSuccess && isSuccess || !existingIsSuccess && !isSuccess) seen.set(key, e);
|
|
198
|
+
}
|
|
199
|
+
const routes = [];
|
|
200
|
+
for (const e of seen.values()) {
|
|
201
|
+
const respHeaders = {};
|
|
202
|
+
for (const [k, v] of Object.entries(e.response.headers)) {
|
|
203
|
+
if (!STRIP_FROM_MOCK_RESPONSE.has(k.toLowerCase())) respHeaders[k] = v;
|
|
204
|
+
}
|
|
205
|
+
routes.push({
|
|
206
|
+
id: crypto.randomUUID(),
|
|
207
|
+
method: e.request.method,
|
|
208
|
+
path: e.request.path,
|
|
209
|
+
statusCode: e.response.status,
|
|
210
|
+
headers: respHeaders,
|
|
211
|
+
body: e.response.binary ? `[binary — recorded from ${upstream}${e.request.path}]` : e.response.body ?? "",
|
|
212
|
+
description: `Recorded from ${upstream} at ${e.timestamp}`
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return { version: "1.0", id: crypto.randomUUID(), name, port, routes };
|
|
216
|
+
}
|
|
217
|
+
function readBody(req) {
|
|
218
|
+
return new Promise((ok, fail) => {
|
|
219
|
+
const chunks = [];
|
|
220
|
+
req.on("data", (c) => chunks.push(c));
|
|
221
|
+
req.on("end", () => ok(Buffer.concat(chunks)));
|
|
222
|
+
req.on("error", fail);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function parseQuery(search) {
|
|
226
|
+
const q = {};
|
|
227
|
+
new URLSearchParams(search).forEach((v, k) => {
|
|
228
|
+
q[k] = v;
|
|
229
|
+
});
|
|
230
|
+
return q;
|
|
231
|
+
}
|
|
232
|
+
function isBinary(contentType) {
|
|
233
|
+
const ct = contentType.toLowerCase();
|
|
234
|
+
return ct.startsWith("image/") || ct.startsWith("audio/") || ct.startsWith("video/") || ct.includes("octet-stream") || ct.includes("application/pdf") || ct.includes("application/zip") || ct.includes("application/gzip") || ct.includes("font/");
|
|
235
|
+
}
|
|
236
|
+
exports.entriesToMockServer = entriesToMockServer;
|
|
237
|
+
exports.getRecorderEntries = getRecorderEntries;
|
|
238
|
+
exports.isRecorderRunning = isRecorderRunning;
|
|
239
|
+
exports.setRecorderHitCallback = setRecorderHitCallback;
|
|
240
|
+
exports.startRecorder = startRecorder;
|
|
241
|
+
exports.stopRecorder = stopRecorder;
|
|
@@ -208,6 +208,13 @@ async function buildEnvVars(environment) {
|
|
|
208
208
|
} catch {
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
|
+
if (vars[v.key] === void 0 && process.env[v.key] !== void 0) {
|
|
212
|
+
vars[v.key] = process.env[v.key];
|
|
213
|
+
}
|
|
214
|
+
} else if (v.secret) {
|
|
215
|
+
if (process.env[v.key] !== void 0) {
|
|
216
|
+
vars[v.key] = process.env[v.key];
|
|
217
|
+
}
|
|
211
218
|
} else {
|
|
212
219
|
vars[v.key] = v.value;
|
|
213
220
|
}
|
package/out/main/index.js
CHANGED
|
@@ -25,7 +25,7 @@ const electron = require("electron");
|
|
|
25
25
|
const path = require("path");
|
|
26
26
|
const fs = require("fs");
|
|
27
27
|
const promises = require("fs/promises");
|
|
28
|
-
const requestHandler = require("./chunks/request-handler-
|
|
28
|
+
const requestHandler = require("./chunks/request-handler-CvqESn11.js");
|
|
29
29
|
const uuid = require("uuid");
|
|
30
30
|
const jsYaml = require("js-yaml");
|
|
31
31
|
const undici = require("undici");
|
|
@@ -36,6 +36,7 @@ const WebSocket = require("ws");
|
|
|
36
36
|
const https = require("https");
|
|
37
37
|
const Ajv = require("ajv");
|
|
38
38
|
const simpleGit = require("simple-git");
|
|
39
|
+
const recorder = require("./chunks/recorder-DFxJgn9c.js");
|
|
39
40
|
require("crypto");
|
|
40
41
|
require("dayjs");
|
|
41
42
|
require("vm");
|
|
@@ -3460,12 +3461,26 @@ function registerGitHandlers(ipc) {
|
|
|
3460
3461
|
staged: result.staged.map((f) => ({ path: f, status: resolveStatus(result, f, true) })),
|
|
3461
3462
|
unstaged: result.modified.filter((f) => !result.staged.includes(f)).concat(result.deleted.filter((f) => !result.staged.includes(f))).map((f) => ({ path: f, status: resolveStatus(result, f, false) })),
|
|
3462
3463
|
untracked: result.not_added.map((f) => ({ path: f, status: "untracked" })),
|
|
3464
|
+
conflicted: result.conflicted,
|
|
3463
3465
|
branch: result.current ?? "",
|
|
3464
3466
|
ahead: result.ahead,
|
|
3465
3467
|
behind: result.behind,
|
|
3466
3468
|
remote: result.tracking ?? null
|
|
3467
3469
|
};
|
|
3468
3470
|
});
|
|
3471
|
+
ipc.handle("git:resolveOurs", async (_e, filePath) => {
|
|
3472
|
+
const g = git();
|
|
3473
|
+
await g.checkout(["--ours", "--", filePath]);
|
|
3474
|
+
await g.add([filePath]);
|
|
3475
|
+
});
|
|
3476
|
+
ipc.handle("git:resolveTheirs", async (_e, filePath) => {
|
|
3477
|
+
const g = git();
|
|
3478
|
+
await g.checkout(["--theirs", "--", filePath]);
|
|
3479
|
+
await g.add([filePath]);
|
|
3480
|
+
});
|
|
3481
|
+
ipc.handle("git:markResolved", async (_e, filePath) => {
|
|
3482
|
+
await git().add([filePath]);
|
|
3483
|
+
});
|
|
3469
3484
|
ipc.handle("git:diff", async (_e, filePath) => {
|
|
3470
3485
|
if (filePath) return git().diff(["--", filePath]);
|
|
3471
3486
|
return git().diff();
|
|
@@ -3551,6 +3566,24 @@ function resolveStatus(result, filePath, staged) {
|
|
|
3551
3566
|
if (result.deleted.includes(filePath)) return "deleted";
|
|
3552
3567
|
return "modified";
|
|
3553
3568
|
}
|
|
3569
|
+
function registerRecordHandlers(ipc, getWebContents) {
|
|
3570
|
+
ipc.handle("record:start", async (_e, config) => {
|
|
3571
|
+
await recorder.startRecorder(config);
|
|
3572
|
+
recorder.setRecorderHitCallback((entry) => {
|
|
3573
|
+
getWebContents()?.send("record:hit", entry);
|
|
3574
|
+
});
|
|
3575
|
+
});
|
|
3576
|
+
ipc.handle("record:stop", async () => {
|
|
3577
|
+
const session = recorder.stopRecorder();
|
|
3578
|
+
recorder.setRecorderHitCallback(null);
|
|
3579
|
+
return session;
|
|
3580
|
+
});
|
|
3581
|
+
ipc.handle("record:isRunning", () => recorder.isRecorderRunning());
|
|
3582
|
+
ipc.handle("record:entries", () => recorder.getRecorderEntries());
|
|
3583
|
+
ipc.handle("record:toMock", (_e, entries, upstream, name, port) => {
|
|
3584
|
+
return recorder.entriesToMockServer(entries, upstream, name, port);
|
|
3585
|
+
});
|
|
3586
|
+
}
|
|
3554
3587
|
if (process.env.ELECTRON_NO_SANDBOX === "1") {
|
|
3555
3588
|
electron.app.commandLine.appendSwitch("no-sandbox");
|
|
3556
3589
|
electron.app.commandLine.appendSwitch("disable-features", "RendererCodeIntegrity");
|
|
@@ -3652,6 +3685,7 @@ electron.app.whenReady().then(async () => {
|
|
|
3652
3685
|
registerDocsHandlers(electron.ipcMain);
|
|
3653
3686
|
registerContractHandlers(electron.ipcMain);
|
|
3654
3687
|
registerGitHandlers(electron.ipcMain);
|
|
3688
|
+
registerRecordHandlers(electron.ipcMain, () => electron.BrowserWindow.getAllWindows()[0]?.webContents ?? null);
|
|
3655
3689
|
createWindow();
|
|
3656
3690
|
electron.app.on("activate", () => {
|
|
3657
3691
|
if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const promises = require("fs/promises");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const recorder = require("./chunks/recorder-DFxJgn9c.js");
|
|
6
|
+
require("http");
|
|
7
|
+
require("crypto");
|
|
8
|
+
require("undici");
|
|
9
|
+
const C = {
|
|
10
|
+
reset: "\x1B[0m",
|
|
11
|
+
bold: "\x1B[1m",
|
|
12
|
+
dim: "\x1B[2m",
|
|
13
|
+
green: "\x1B[32m",
|
|
14
|
+
red: "\x1B[31m",
|
|
15
|
+
yellow: "\x1B[33m",
|
|
16
|
+
cyan: "\x1B[36m",
|
|
17
|
+
gray: "\x1B[90m",
|
|
18
|
+
white: "\x1B[97m"
|
|
19
|
+
};
|
|
20
|
+
function color(str, ...codes) {
|
|
21
|
+
return process.stdout.isTTY ? codes.join("") + str + C.reset : str;
|
|
22
|
+
}
|
|
23
|
+
function methodBadge(method) {
|
|
24
|
+
const m = method.toUpperCase();
|
|
25
|
+
const c = m === "GET" ? C.green : m === "POST" ? C.cyan : m === "PUT" || m === "PATCH" ? C.yellow : m === "DELETE" ? C.red : C.white;
|
|
26
|
+
return color(m.padEnd(7), c, C.bold);
|
|
27
|
+
}
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const args = {};
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const arg = argv[i];
|
|
32
|
+
if (!arg.startsWith("--")) continue;
|
|
33
|
+
const key = arg.slice(2);
|
|
34
|
+
const next = argv[i + 1];
|
|
35
|
+
if (!next || next.startsWith("--")) {
|
|
36
|
+
args[key] = true;
|
|
37
|
+
} else {
|
|
38
|
+
if (key === "mask" || key === "ignore") {
|
|
39
|
+
const existing = args[key];
|
|
40
|
+
args[key] = Array.isArray(existing) ? [...existing, next] : [next];
|
|
41
|
+
} else {
|
|
42
|
+
args[key] = next;
|
|
43
|
+
}
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return args;
|
|
48
|
+
}
|
|
49
|
+
async function main() {
|
|
50
|
+
const args = parseArgs(process.argv.slice(2));
|
|
51
|
+
if (args.help) {
|
|
52
|
+
console.log(
|
|
53
|
+
"\nUsage:\n api-spector record --upstream <url> [options]\n\nOptions:\n --upstream <url> Real API base URL (required)\n --port <n> Local port (default: 4001)\n --output <path> Output directory (default: ./recordings)\n --mask <header> Mask header value with *** (repeatable)\n --ignore <header> Omit header from recordings (repeatable)\n"
|
|
54
|
+
);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
const upstream = args.upstream?.replace(/\/$/, "");
|
|
58
|
+
if (!upstream) {
|
|
59
|
+
console.error(color("Error: --upstream <url> is required", C.red));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const port = parseInt(args.port ?? "4001", 10);
|
|
63
|
+
const outputDir = path.resolve(args.output ?? "./recordings");
|
|
64
|
+
const extraMask = Array.isArray(args.mask) ? args.mask : args.mask ? [args.mask] : [];
|
|
65
|
+
const extraIgnore = Array.isArray(args.ignore) ? args.ignore : args.ignore ? [args.ignore] : [];
|
|
66
|
+
recorder.setRecorderHitCallback((entry) => {
|
|
67
|
+
const { method, path: path2 } = entry.request;
|
|
68
|
+
const { status, binary } = entry.response;
|
|
69
|
+
const sc = status < 300 ? C.green : status < 400 ? C.cyan : C.red;
|
|
70
|
+
const st = status > 0 ? color(String(status), sc, C.bold) : color("ERR", C.red, C.bold);
|
|
71
|
+
const bin = binary ? color(" [binary]", C.yellow) : "";
|
|
72
|
+
console.log(` ${methodBadge(method)} ${color(path2, C.white)} ${st} ${color(`${entry.durationMs}ms`, C.gray)}${bin}`);
|
|
73
|
+
});
|
|
74
|
+
await recorder.startRecorder({ upstream, port, maskHeaders: extraMask, ignoreHeaders: extraIgnore });
|
|
75
|
+
console.log("");
|
|
76
|
+
console.log(color(" API Spector — Record Proxy", C.bold, C.white));
|
|
77
|
+
console.log(color(` Upstream: ${upstream}`, C.gray));
|
|
78
|
+
console.log(color(` Listening: http://localhost:${port}`, C.cyan));
|
|
79
|
+
console.log(color(` Output: ${outputDir}`, C.gray));
|
|
80
|
+
console.log(color(" Press Ctrl+C to stop and save recordings.\n", C.dim));
|
|
81
|
+
async function shutdown() {
|
|
82
|
+
console.log("");
|
|
83
|
+
const session = recorder.stopRecorder();
|
|
84
|
+
recorder.setRecorderHitCallback(null);
|
|
85
|
+
if (session.entries.length === 0) {
|
|
86
|
+
console.log(color(" No requests recorded.", C.yellow));
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
await promises.mkdir(outputDir, { recursive: true });
|
|
90
|
+
const slug = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
91
|
+
const sessionPath = path.join(outputDir, `session-${slug}.recording.json`);
|
|
92
|
+
await promises.writeFile(sessionPath, JSON.stringify(session, null, 2), "utf8");
|
|
93
|
+
const mockName = `Recorded — ${new URL(upstream).hostname} ${slug}`;
|
|
94
|
+
const mockServer = recorder.entriesToMockServer(session.entries, upstream, mockName, port);
|
|
95
|
+
const mockPath = path.join(outputDir, `session-${slug}.mock.json`);
|
|
96
|
+
await promises.writeFile(mockPath, JSON.stringify(mockServer, null, 2), "utf8");
|
|
97
|
+
console.log(color(` Recorded ${session.entries.length} request${session.entries.length !== 1 ? "s" : ""}.`, C.green, C.bold));
|
|
98
|
+
console.log(color(` Session: ${sessionPath}`, C.gray));
|
|
99
|
+
console.log(color(` Mock stubs: ${mockPath}`, C.gray));
|
|
100
|
+
console.log("");
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
process.on("SIGINT", shutdown);
|
|
104
|
+
process.on("SIGTERM", shutdown);
|
|
105
|
+
}
|
|
106
|
+
main().catch((err) => {
|
|
107
|
+
console.error(color(`Fatal: ${err instanceof Error ? err.message : String(err)}`, "\x1B[31m"));
|
|
108
|
+
process.exit(2);
|
|
109
|
+
});
|