@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 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;
@@ -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-9MdHOWVf.js");
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
+ });