@testsmith/api-spector 0.1.2 → 0.1.4
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/LICENSE +19 -667
- package/bin/cli.js +13 -4
- package/out/main/chunks/recorder-DFxJgn9c.js +241 -0
- package/out/main/index.js +98 -2
- package/out/main/record.js +109 -0
- package/out/main/runner.js +32 -4
- package/out/preload/index.js +17 -0
- package/out/renderer/assets/{index-BXpiAoKF.js → index-D-jqC4fC.js} +9602 -8966
- package/out/renderer/assets/index-Dhvg6Rst.css +2 -0
- package/out/renderer/index.html +2 -2
- package/package.json +5 -4
- package/readme.md +5 -1
- 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;
|
package/out/main/index.js
CHANGED
|
@@ -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");
|
|
@@ -2398,8 +2399,56 @@ function registerRunnerHandler(ipc) {
|
|
|
2398
2399
|
let runEnvVars = { ...envVars };
|
|
2399
2400
|
let runCollectionVars = {};
|
|
2400
2401
|
let runGlobals = { ...globals };
|
|
2402
|
+
const failedScopes = /* @__PURE__ */ new Set();
|
|
2403
|
+
const skipRequests = /* @__PURE__ */ new Set();
|
|
2401
2404
|
for (const item of items) {
|
|
2402
|
-
const
|
|
2405
|
+
const { isHook, hookType, scopeId, scopeAncestors, mainRequestId } = item;
|
|
2406
|
+
let skipReason;
|
|
2407
|
+
if (isHook) {
|
|
2408
|
+
if (hookType === "beforeAll") {
|
|
2409
|
+
if ((scopeAncestors ?? []).some((id) => failedScopes.has(id))) {
|
|
2410
|
+
skipReason = "Skipped — outer scope hook failed";
|
|
2411
|
+
}
|
|
2412
|
+
} else if (hookType === "before") {
|
|
2413
|
+
const allScopes = [...scopeAncestors ?? [], scopeId].filter(Boolean);
|
|
2414
|
+
if (allScopes.some((id) => failedScopes.has(id))) {
|
|
2415
|
+
skipReason = "Skipped — scope hook failed";
|
|
2416
|
+
} else if (mainRequestId && skipRequests.has(mainRequestId)) {
|
|
2417
|
+
skipReason = "Skipped — before hook failed";
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
} else {
|
|
2421
|
+
const allScopes = [...item.scopeAncestors ?? [], item.scopeId].filter(Boolean);
|
|
2422
|
+
if (allScopes.some((id) => failedScopes.has(id))) {
|
|
2423
|
+
skipReason = "Skipped — beforeAll hook failed";
|
|
2424
|
+
} else if (skipRequests.has(item.request.id)) {
|
|
2425
|
+
skipReason = "Skipped — before hook failed";
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
if (skipReason) {
|
|
2429
|
+
const skipped = {
|
|
2430
|
+
requestId: item.request.id,
|
|
2431
|
+
name: item.request.name,
|
|
2432
|
+
method: item.request.method,
|
|
2433
|
+
resolvedUrl: item.request.url,
|
|
2434
|
+
status: "failed",
|
|
2435
|
+
error: skipReason,
|
|
2436
|
+
isHook: item.isHook,
|
|
2437
|
+
hookType: item.hookType,
|
|
2438
|
+
scopeId: item.scopeId,
|
|
2439
|
+
iterationLabel: item.iterationLabel
|
|
2440
|
+
};
|
|
2441
|
+
summary.failed++;
|
|
2442
|
+
event.sender.send("runner:progress", skipped);
|
|
2443
|
+
continue;
|
|
2444
|
+
}
|
|
2445
|
+
const runningUpdate = {
|
|
2446
|
+
status: "running",
|
|
2447
|
+
iterationLabel: item.iterationLabel,
|
|
2448
|
+
isHook: item.isHook,
|
|
2449
|
+
hookType: item.hookType,
|
|
2450
|
+
scopeId: item.scopeId
|
|
2451
|
+
};
|
|
2403
2452
|
event.sender.send("runner:progress", { requestId: item.request.id, ...runningUpdate });
|
|
2404
2453
|
const { result, updatedEnvVars, updatedCollectionVars, updatedGlobals } = await executeOne(
|
|
2405
2454
|
item.request,
|
|
@@ -2413,10 +2462,23 @@ function registerRunnerHandler(ipc) {
|
|
|
2413
2462
|
runEnvVars = updatedEnvVars;
|
|
2414
2463
|
runCollectionVars = updatedCollectionVars;
|
|
2415
2464
|
runGlobals = updatedGlobals;
|
|
2465
|
+
if (isHook && result.status !== "passed") {
|
|
2466
|
+
if (hookType === "beforeAll" && scopeId) {
|
|
2467
|
+
failedScopes.add(scopeId);
|
|
2468
|
+
} else if (hookType === "before" && mainRequestId) {
|
|
2469
|
+
skipRequests.add(mainRequestId);
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2416
2472
|
if (result.status === "passed") summary.passed++;
|
|
2417
2473
|
else if (result.status === "failed") summary.failed++;
|
|
2418
2474
|
else summary.errors++;
|
|
2419
|
-
event.sender.send("runner:progress", {
|
|
2475
|
+
event.sender.send("runner:progress", {
|
|
2476
|
+
...result,
|
|
2477
|
+
iterationLabel: item.iterationLabel,
|
|
2478
|
+
isHook: item.isHook,
|
|
2479
|
+
hookType: item.hookType,
|
|
2480
|
+
scopeId: item.scopeId
|
|
2481
|
+
});
|
|
2420
2482
|
if (requestDelay > 0 && item !== items[items.length - 1]) {
|
|
2421
2483
|
await sleep(requestDelay);
|
|
2422
2484
|
}
|
|
@@ -3460,12 +3522,26 @@ function registerGitHandlers(ipc) {
|
|
|
3460
3522
|
staged: result.staged.map((f) => ({ path: f, status: resolveStatus(result, f, true) })),
|
|
3461
3523
|
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
3524
|
untracked: result.not_added.map((f) => ({ path: f, status: "untracked" })),
|
|
3525
|
+
conflicted: result.conflicted,
|
|
3463
3526
|
branch: result.current ?? "",
|
|
3464
3527
|
ahead: result.ahead,
|
|
3465
3528
|
behind: result.behind,
|
|
3466
3529
|
remote: result.tracking ?? null
|
|
3467
3530
|
};
|
|
3468
3531
|
});
|
|
3532
|
+
ipc.handle("git:resolveOurs", async (_e, filePath) => {
|
|
3533
|
+
const g = git();
|
|
3534
|
+
await g.checkout(["--ours", "--", filePath]);
|
|
3535
|
+
await g.add([filePath]);
|
|
3536
|
+
});
|
|
3537
|
+
ipc.handle("git:resolveTheirs", async (_e, filePath) => {
|
|
3538
|
+
const g = git();
|
|
3539
|
+
await g.checkout(["--theirs", "--", filePath]);
|
|
3540
|
+
await g.add([filePath]);
|
|
3541
|
+
});
|
|
3542
|
+
ipc.handle("git:markResolved", async (_e, filePath) => {
|
|
3543
|
+
await git().add([filePath]);
|
|
3544
|
+
});
|
|
3469
3545
|
ipc.handle("git:diff", async (_e, filePath) => {
|
|
3470
3546
|
if (filePath) return git().diff(["--", filePath]);
|
|
3471
3547
|
return git().diff();
|
|
@@ -3551,6 +3627,24 @@ function resolveStatus(result, filePath, staged) {
|
|
|
3551
3627
|
if (result.deleted.includes(filePath)) return "deleted";
|
|
3552
3628
|
return "modified";
|
|
3553
3629
|
}
|
|
3630
|
+
function registerRecordHandlers(ipc, getWebContents) {
|
|
3631
|
+
ipc.handle("record:start", async (_e, config) => {
|
|
3632
|
+
await recorder.startRecorder(config);
|
|
3633
|
+
recorder.setRecorderHitCallback((entry) => {
|
|
3634
|
+
getWebContents()?.send("record:hit", entry);
|
|
3635
|
+
});
|
|
3636
|
+
});
|
|
3637
|
+
ipc.handle("record:stop", async () => {
|
|
3638
|
+
const session = recorder.stopRecorder();
|
|
3639
|
+
recorder.setRecorderHitCallback(null);
|
|
3640
|
+
return session;
|
|
3641
|
+
});
|
|
3642
|
+
ipc.handle("record:isRunning", () => recorder.isRecorderRunning());
|
|
3643
|
+
ipc.handle("record:entries", () => recorder.getRecorderEntries());
|
|
3644
|
+
ipc.handle("record:toMock", (_e, entries, upstream, name, port) => {
|
|
3645
|
+
return recorder.entriesToMockServer(entries, upstream, name, port);
|
|
3646
|
+
});
|
|
3647
|
+
}
|
|
3554
3648
|
if (process.env.ELECTRON_NO_SANDBOX === "1") {
|
|
3555
3649
|
electron.app.commandLine.appendSwitch("no-sandbox");
|
|
3556
3650
|
electron.app.commandLine.appendSwitch("disable-features", "RendererCodeIntegrity");
|
|
@@ -3652,6 +3746,8 @@ electron.app.whenReady().then(async () => {
|
|
|
3652
3746
|
registerDocsHandlers(electron.ipcMain);
|
|
3653
3747
|
registerContractHandlers(electron.ipcMain);
|
|
3654
3748
|
registerGitHandlers(electron.ipcMain);
|
|
3749
|
+
registerRecordHandlers(electron.ipcMain, () => electron.BrowserWindow.getAllWindows()[0]?.webContents ?? null);
|
|
3750
|
+
electron.ipcMain.handle("shell:openExternal", (_e, url) => electron.shell.openExternal(url));
|
|
3655
3751
|
createWindow();
|
|
3656
3752
|
electron.app.on("activate", () => {
|
|
3657
3753
|
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
|
+
});
|
package/out/main/runner.js
CHANGED
|
@@ -22,6 +22,8 @@ function buildJsonReport(results, summary, meta = {}) {
|
|
|
22
22
|
method: r.method,
|
|
23
23
|
url: r.resolvedUrl,
|
|
24
24
|
status: r.status,
|
|
25
|
+
isHook: r.isHook ?? false,
|
|
26
|
+
hookType: r.hookType ?? null,
|
|
25
27
|
httpStatus: r.httpStatus ?? null,
|
|
26
28
|
durationMs: r.durationMs ?? null,
|
|
27
29
|
iterationLabel: r.iterationLabel ?? null,
|
|
@@ -54,11 +56,18 @@ function buildHtmlReport(results, summary, meta = {}) {
|
|
|
54
56
|
const collection = meta.collection ?? "API Tests";
|
|
55
57
|
const env = meta.environment ?? "—";
|
|
56
58
|
const passRate = summary.total > 0 ? Math.round(summary.passed / summary.total * 100) : 0;
|
|
59
|
+
const HOOK_LABELS = {
|
|
60
|
+
beforeAll: "BEFORE ALL",
|
|
61
|
+
before: "BEFORE",
|
|
62
|
+
after: "AFTER",
|
|
63
|
+
afterAll: "AFTER ALL"
|
|
64
|
+
};
|
|
57
65
|
const cards = results.map((r, idx) => {
|
|
58
66
|
const statusCls = r.status === "passed" ? "badge-pass" : r.status === "failed" ? "badge-fail" : "badge-err";
|
|
59
67
|
const httpCls = r.httpStatus && r.httpStatus < 300 ? "http-ok" : r.httpStatus && r.httpStatus < 400 ? "http-redir" : "http-err";
|
|
60
68
|
const dur = r.durationMs != null ? `${r.durationMs} ms` : "—";
|
|
61
69
|
const label = r.iterationLabel ? ` <span class="muted">#${esc(r.iterationLabel)}</span>` : "";
|
|
70
|
+
const hookLabel = r.isHook && r.hookType ? HOOK_LABELS[r.hookType] ?? r.hookType.toUpperCase() : null;
|
|
62
71
|
const testRows = (r.testResults ?? []).map(
|
|
63
72
|
(t) => `<div class="test-row ${t.passed ? "test-pass" : "test-fail"}">
|
|
64
73
|
<span class="dot">${t.passed ? "✓" : "✗"}</span> ${esc(t.name)}
|
|
@@ -91,12 +100,13 @@ function buildHtmlReport(results, summary, meta = {}) {
|
|
|
91
100
|
${headersTable(resp.headers)}
|
|
92
101
|
${resp.body ? `<div class="section-label">Body</div><pre class="code-block">${prettyJson(resp.body)}</pre>` : ""}
|
|
93
102
|
</div>` : "";
|
|
103
|
+
const hookCls = hookLabel ? r.hookType?.startsWith("before") ? "card-hook-before" : "card-hook-after" : "";
|
|
94
104
|
return `
|
|
95
|
-
<div class="card" id="r${idx}">
|
|
105
|
+
<div class="card ${hookCls}" id="r${idx}">
|
|
96
106
|
<div class="card-header" onclick="toggle(${idx})">
|
|
97
107
|
<span class="chevron" id="ch${idx}">▶</span>
|
|
98
108
|
<span class="badge ${statusCls}">${r.status}</span>
|
|
99
|
-
|
|
109
|
+
${hookLabel ? `<span class="hook-badge ${r.hookType?.startsWith("before") ? "hook-before" : "hook-after"}">${hookLabel}</span>` : `<span class="method mono">${esc(r.method)}</span>`}
|
|
100
110
|
<span class="card-name">${esc(r.name)}${label}</span>
|
|
101
111
|
<span class="card-url muted">${esc(r.resolvedUrl ?? "")}</span>
|
|
102
112
|
<span class="dur muted">${dur}</span>
|
|
@@ -177,6 +187,16 @@ function buildHtmlReport(results, summary, meta = {}) {
|
|
|
177
187
|
.dot { font-weight: 700; }
|
|
178
188
|
/* Console */
|
|
179
189
|
.err-row { color: #f85149; font-size: 12px; }
|
|
190
|
+
/* Footer */
|
|
191
|
+
.report-footer { margin-top: 32px; text-align: center; font-size: 11px; color: #484f58; }
|
|
192
|
+
.footer-link { color: #3d7fb2; text-decoration: none; }
|
|
193
|
+
.footer-link:hover { text-decoration: underline; }
|
|
194
|
+
/* Hook cards */
|
|
195
|
+
.card-hook-before { border-left: 3px solid #7c3aed; }
|
|
196
|
+
.card-hook-after { border-left: 3px solid #0e7490; }
|
|
197
|
+
.hook-badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 10px; font-weight: 700; white-space: nowrap; letter-spacing: .04em; }
|
|
198
|
+
.hook-before { background: #4c1d95; color: #c4b5fd; }
|
|
199
|
+
.hook-after { background: #164e63; color: #67e8f9; }
|
|
180
200
|
</style>
|
|
181
201
|
</head>
|
|
182
202
|
<body>
|
|
@@ -192,6 +212,7 @@ function buildHtmlReport(results, summary, meta = {}) {
|
|
|
192
212
|
<div class="stat"><div class="stat-val">${summary.durationMs} ms</div><div class="stat-lbl">Duration</div></div>
|
|
193
213
|
</div>
|
|
194
214
|
<div class="cards">${cards}</div>
|
|
215
|
+
<div class="report-footer">Generated by <a href="https://testsmith.io" target="_blank" rel="noopener" class="footer-link">Testsmith</a> · api Spector</div>
|
|
195
216
|
</div>
|
|
196
217
|
<script>
|
|
197
218
|
function toggle(i) {
|
|
@@ -211,9 +232,16 @@ function buildJUnitReport(results, summary, meta = {}) {
|
|
|
211
232
|
const suiteName = esc(meta.collection ?? "API Tests");
|
|
212
233
|
const totalSec = (summary.durationMs / 1e3).toFixed(3);
|
|
213
234
|
const ts = meta.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
235
|
+
const HOOK_JUNIT_LABELS = {
|
|
236
|
+
beforeAll: "Hook: Before All",
|
|
237
|
+
before: "Hook: Before",
|
|
238
|
+
after: "Hook: After",
|
|
239
|
+
afterAll: "Hook: After All"
|
|
240
|
+
};
|
|
214
241
|
const cases = results.map((r) => {
|
|
215
242
|
const label = r.iterationLabel ? ` #${r.iterationLabel}` : "";
|
|
216
|
-
const
|
|
243
|
+
const hookPrefix = r.isHook && r.hookType ? `[${HOOK_JUNIT_LABELS[r.hookType] ?? "Hook"}] ` : "";
|
|
244
|
+
const name = esc(hookPrefix + r.name + label);
|
|
217
245
|
const classname = esc(`${r.method} ${r.resolvedUrl}`);
|
|
218
246
|
const timeSec = ((r.durationMs ?? 0) / 1e3).toFixed(3);
|
|
219
247
|
const failures = [];
|
|
@@ -251,7 +279,7 @@ function collectTagged(folder, requests, collectionVars, filterTags) {
|
|
|
251
279
|
const results = [];
|
|
252
280
|
for (const reqId of folder.requestIds) {
|
|
253
281
|
const req = requests[reqId];
|
|
254
|
-
if (!req) continue;
|
|
282
|
+
if (!req || req.hookType) continue;
|
|
255
283
|
const tags = req.meta?.tags ?? [];
|
|
256
284
|
if (filterTags.length > 0 && !filterTags.some((t) => tags.includes(t))) continue;
|
|
257
285
|
results.push({ request: req, collectionVars });
|
package/out/preload/index.js
CHANGED
|
@@ -101,6 +101,23 @@ const api = {
|
|
|
101
101
|
gitSetRemoteUrl: (name, url) => electron.ipcRenderer.invoke("git:setRemoteUrl", name, url),
|
|
102
102
|
gitRemoveRemote: (name) => electron.ipcRenderer.invoke("git:removeRemote", name),
|
|
103
103
|
gitWriteCiFile: (relPath, content) => electron.ipcRenderer.invoke("git:writeCiFile", relPath, content),
|
|
104
|
+
gitResolveOurs: (filePath) => electron.ipcRenderer.invoke("git:resolveOurs", filePath),
|
|
105
|
+
gitResolveTheirs: (filePath) => electron.ipcRenderer.invoke("git:resolveTheirs", filePath),
|
|
106
|
+
gitMarkResolved: (filePath) => electron.ipcRenderer.invoke("git:markResolved", filePath),
|
|
107
|
+
// ─── Recorder ─────────────────────────────────────────────────────────────
|
|
108
|
+
recordStart: (config) => electron.ipcRenderer.invoke("record:start", config),
|
|
109
|
+
recordStop: () => electron.ipcRenderer.invoke("record:stop"),
|
|
110
|
+
recordIsRunning: () => electron.ipcRenderer.invoke("record:isRunning"),
|
|
111
|
+
recordEntries: () => electron.ipcRenderer.invoke("record:entries"),
|
|
112
|
+
recordToMock: (entries, upstream, name, port) => electron.ipcRenderer.invoke("record:toMock", entries, upstream, name, port),
|
|
113
|
+
onRecordHit: (cb) => {
|
|
114
|
+
electron.ipcRenderer.on("record:hit", (_e, entry) => cb(entry));
|
|
115
|
+
},
|
|
116
|
+
offRecordHit: () => {
|
|
117
|
+
electron.ipcRenderer.removeAllListeners("record:hit");
|
|
118
|
+
},
|
|
119
|
+
// ─── Shell ────────────────────────────────────────────────────────────────
|
|
120
|
+
openExternal: (url) => electron.ipcRenderer.invoke("shell:openExternal", url),
|
|
104
121
|
// ─── Zoom ─────────────────────────────────────────────────────────────────
|
|
105
122
|
setZoomFactor: (factor) => electron.webFrame.setZoomFactor(factor),
|
|
106
123
|
// ─── Platform ─────────────────────────────────────────────────────────────
|