@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/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 --workspace <path> Run tests from CLI')
16
- console.log(' api-spector mock --workspace <path> Start mock servers from CLI')
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 --help Show run options')
20
- console.log(' api-spector mock --help Show mock options')
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 runningUpdate = { status: "running", iterationLabel: item.iterationLabel };
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", { ...result, iterationLabel: item.iterationLabel });
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
+ });
@@ -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
- <span class="method mono">${esc(r.method)}</span>
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 name = esc(r.name + label);
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 });
@@ -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 ─────────────────────────────────────────────────────────────