@testsmith/api-spector 0.0.1

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 ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const path = require('path')
5
+ const { spawn } = require('child_process')
6
+ const electron = require('electron')
7
+
8
+ const [, , cmd = 'ui', ...rest] = process.argv
9
+
10
+ function printHelp() {
11
+ console.log('')
12
+ console.log(' api Spector — local-first API testing tool')
13
+ console.log('')
14
+ console.log(' Usage:')
15
+ console.log(' api-spector ui Launch the app')
16
+ console.log(' api-spector run --workspace <path> Run tests from CLI')
17
+ console.log(' api-spector mock --workspace <path> Start mock servers from CLI')
18
+ console.log('')
19
+ console.log(' Options:')
20
+ console.log(' api-spector run --help Show run options')
21
+ console.log(' api-spector mock --help Show mock options')
22
+ console.log('')
23
+ }
24
+
25
+ if (cmd === '--help' || cmd === '-h') {
26
+ printHelp()
27
+ process.exit(0)
28
+ } else if (cmd === 'ui') {
29
+ const appDir = path.join(__dirname, '..')
30
+ const proc = spawn(String(electron), [appDir], {
31
+ stdio: 'inherit',
32
+ env: process.env,
33
+ })
34
+ proc.on('close', code => process.exit(code ?? 0))
35
+ } else if (cmd === 'run') {
36
+ const runnerPath = path.join(__dirname, '..', 'out', 'main', 'runner.js')
37
+ const proc = spawn(process.execPath, [runnerPath, ...rest], {
38
+ stdio: 'inherit',
39
+ env: process.env,
40
+ })
41
+ proc.on('close', code => process.exit(code ?? 0))
42
+ } else if (cmd === 'mock') {
43
+ const mockPath = path.join(__dirname, '..', 'out', 'main', 'mock.js')
44
+ const proc = spawn(process.execPath, [mockPath, ...rest], {
45
+ stdio: 'inherit',
46
+ env: process.env,
47
+ })
48
+ proc.on('close', code => process.exit(code ?? 0))
49
+ } else {
50
+ console.error(`api Spector — unknown command: "${cmd}"`)
51
+ printHelp()
52
+ process.exit(1)
53
+ }
package/build/icon.png ADDED
Binary file
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ const http = require("http");
3
+ const crypto = require("crypto");
4
+ const running = /* @__PURE__ */ new Map();
5
+ const liveRoutes = /* @__PURE__ */ new Map();
6
+ let hitCallback = null;
7
+ function setHitCallback(cb) {
8
+ hitCallback = cb;
9
+ }
10
+ function updateMockRoutes(id, routes) {
11
+ liveRoutes.set(id, routes);
12
+ }
13
+ function matchPath(pattern, urlPath) {
14
+ const regexStr = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/:[^/]+/g, "[^/]+");
15
+ const regex = new RegExp("^" + regexStr + "/?$");
16
+ return regex.test(urlPath.split("?")[0]);
17
+ }
18
+ function findRoute(routes, method, urlPath) {
19
+ const path = urlPath.split("?")[0];
20
+ return routes.find((r) => r.method === method && matchPath(r.path, path)) ?? routes.find((r) => r.method === "ANY" && matchPath(r.path, path)) ?? null;
21
+ }
22
+ async function startMock(server) {
23
+ if (running.has(server.id)) await stopMock(server.id);
24
+ liveRoutes.set(server.id, server.routes);
25
+ const httpServer = http.createServer((req, res) => {
26
+ const reqStart = Date.now();
27
+ const method = (req.method ?? "GET").toUpperCase();
28
+ const urlPath = req.url ?? "/";
29
+ if (urlPath === "/favicon.ico") {
30
+ res.writeHead(204);
31
+ res.end();
32
+ return;
33
+ }
34
+ const routes = liveRoutes.get(server.id) ?? [];
35
+ const route = findRoute(routes, method, urlPath);
36
+ if (!route) {
37
+ res.writeHead(404, { "Content-Type": "application/json" });
38
+ res.end(JSON.stringify({ error: "No matching mock route", method, path: urlPath }));
39
+ hitCallback?.({
40
+ id: crypto.randomUUID(),
41
+ serverId: server.id,
42
+ timestamp: reqStart,
43
+ method,
44
+ path: urlPath,
45
+ matchedRouteId: null,
46
+ status: 404,
47
+ durationMs: Date.now() - reqStart
48
+ });
49
+ return;
50
+ }
51
+ const respond = () => {
52
+ const headers = { "Content-Type": "application/json", ...route.headers };
53
+ res.writeHead(route.statusCode, headers);
54
+ res.end(route.body);
55
+ hitCallback?.({
56
+ id: crypto.randomUUID(),
57
+ serverId: server.id,
58
+ timestamp: reqStart,
59
+ method,
60
+ path: urlPath,
61
+ matchedRouteId: route.id,
62
+ status: route.statusCode,
63
+ durationMs: Date.now() - reqStart
64
+ });
65
+ };
66
+ if (route.delay && route.delay > 0) {
67
+ setTimeout(respond, route.delay);
68
+ } else {
69
+ respond();
70
+ }
71
+ });
72
+ await new Promise((resolve, reject) => {
73
+ httpServer.once("error", reject);
74
+ httpServer.listen(server.port, "127.0.0.1", resolve);
75
+ });
76
+ running.set(server.id, httpServer);
77
+ }
78
+ async function stopMock(id) {
79
+ const srv = running.get(id);
80
+ if (!srv) return;
81
+ await new Promise(
82
+ (resolve, reject) => srv.close((err) => err ? reject(err) : resolve())
83
+ );
84
+ running.delete(id);
85
+ liveRoutes.delete(id);
86
+ }
87
+ function isRunning(id) {
88
+ return running.has(id);
89
+ }
90
+ function getRunningIds() {
91
+ return [...running.keys()];
92
+ }
93
+ async function stopAll() {
94
+ await Promise.all([...running.keys()].map(stopMock));
95
+ }
96
+ exports.getRunningIds = getRunningIds;
97
+ exports.isRunning = isRunning;
98
+ exports.setHitCallback = setHitCallback;
99
+ exports.startMock = startMock;
100
+ exports.stopAll = stopAll;
101
+ exports.stopMock = stopMock;
102
+ exports.updateMockRoutes = updateMockRoutes;
@@ -0,0 +1,399 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") {
10
+ for (let key of __getOwnPropNames(from))
11
+ if (!__hasOwnProp.call(to, key) && key !== except)
12
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
+ }
14
+ return to;
15
+ };
16
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
17
+ // If the importer is in node compatibility mode or this is not an ESM
18
+ // file that has been converted to a CommonJS file using a Babel-
19
+ // compatible transform (i.e. "__esModule" has not been set), then set
20
+ // "default" to the CommonJS "module.exports" for node compatibility.
21
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
22
+ mod
23
+ ));
24
+ const promises = require("fs/promises");
25
+ const path = require("path");
26
+ const crypto = require("crypto");
27
+ const vm = require("vm");
28
+ const dayjs = require("dayjs");
29
+ const tv4 = require("tv4");
30
+ function _interopNamespaceDefault(e) {
31
+ const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
32
+ if (e) {
33
+ for (const k in e) {
34
+ if (k !== "default") {
35
+ const d = Object.getOwnPropertyDescriptor(e, k);
36
+ Object.defineProperty(n, k, d.get ? d : {
37
+ enumerable: true,
38
+ get: () => e[k]
39
+ });
40
+ }
41
+ }
42
+ }
43
+ n.default = e;
44
+ return Object.freeze(n);
45
+ }
46
+ const vm__namespace = /* @__PURE__ */ _interopNamespaceDefault(vm);
47
+ let globals = {};
48
+ let currentDir = null;
49
+ function globalsPath(dir) {
50
+ return path.join(dir, "globals.json");
51
+ }
52
+ async function loadGlobals(workspaceDir) {
53
+ currentDir = workspaceDir;
54
+ try {
55
+ const raw = await promises.readFile(globalsPath(workspaceDir), "utf8");
56
+ globals = JSON.parse(raw);
57
+ } catch {
58
+ globals = {};
59
+ }
60
+ return { ...globals };
61
+ }
62
+ async function persistGlobals() {
63
+ if (!currentDir) return;
64
+ await promises.writeFile(globalsPath(currentDir), JSON.stringify(globals, null, 2), "utf8");
65
+ }
66
+ function getGlobals() {
67
+ return { ...globals };
68
+ }
69
+ function setGlobals(next) {
70
+ globals = { ...next };
71
+ }
72
+ function patchGlobals(patch) {
73
+ globals = { ...globals, ...patch };
74
+ }
75
+ const MASTER_KEY_ENV = "API_SPECTOR_MASTER_KEY";
76
+ function registerSecretHandlers(ipc) {
77
+ ipc.handle("secret:checkMasterKey", () => {
78
+ return { set: Boolean(process.env[MASTER_KEY_ENV]) };
79
+ });
80
+ ipc.handle("secret:setMasterKey", (_e, value) => {
81
+ process.env[MASTER_KEY_ENV] = value;
82
+ });
83
+ }
84
+ function decryptSecret(encrypted, salt, iv, password) {
85
+ const saltBuf = Buffer.from(salt, "base64");
86
+ const ivBuf = Buffer.from(iv, "base64");
87
+ const encBuf = Buffer.from(encrypted, "base64");
88
+ const key = crypto.pbkdf2Sync(password, saltBuf, 1e5, 32, "sha256");
89
+ const authTag = encBuf.subarray(encBuf.length - 16);
90
+ const ciphertext = encBuf.subarray(0, encBuf.length - 16);
91
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, ivBuf);
92
+ decipher.setAuthTag(authTag);
93
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
94
+ }
95
+ async function getSecret(ref) {
96
+ return process.env[ref] ?? null;
97
+ }
98
+ function interpolate(str, vars) {
99
+ return str.replace(/\{\{([^}]+)\}\}/g, (_, key) => vars[key.trim()] ?? `{{${key}}}`);
100
+ }
101
+ function buildUrl(baseUrl, params, vars) {
102
+ const url = interpolate(baseUrl, vars);
103
+ const enabled = params.filter((p) => p.enabled && p.key);
104
+ if (!enabled.length) return url;
105
+ const sep = url.includes("?") ? "&" : "?";
106
+ const qs = enabled.map((p) => `${encodeURIComponent(interpolate(p.key, vars))}=${encodeURIComponent(interpolate(p.value, vars))}`).join("&");
107
+ return url + sep + qs;
108
+ }
109
+ async function buildEnvVars(environment) {
110
+ const vars = {};
111
+ if (!environment) return vars;
112
+ const masterKey = process.env["API_SPECTOR_MASTER_KEY"];
113
+ for (const v of environment.variables) {
114
+ if (!v.enabled) continue;
115
+ if (v.envRef) {
116
+ const envValue = process.env[v.envRef];
117
+ if (envValue !== void 0) vars[v.key] = envValue;
118
+ } else if (v.secret && v.secretEncrypted && v.secretSalt && v.secretIv) {
119
+ if (masterKey) {
120
+ try {
121
+ vars[v.key] = decryptSecret(v.secretEncrypted, v.secretSalt, v.secretIv, masterKey);
122
+ } catch {
123
+ }
124
+ }
125
+ } else {
126
+ vars[v.key] = v.value;
127
+ }
128
+ }
129
+ return vars;
130
+ }
131
+ function mergeVars(envVars, collectionVars, globals2, localVars = {}) {
132
+ return { ...globals2, ...collectionVars, ...envVars, ...localVars };
133
+ }
134
+ let _fakerCache = null;
135
+ async function getFaker() {
136
+ if (!_fakerCache) _fakerCache = await import("@faker-js/faker");
137
+ return _fakerCache.faker;
138
+ }
139
+ function makeAsserter(value, negated = false) {
140
+ function doAssert(condition, msg) {
141
+ const passed = negated ? !condition : condition;
142
+ if (!passed) throw new AssertionError(msg);
143
+ }
144
+ const asserter = {};
145
+ const chainer = { get: () => asserter };
146
+ for (const key of ["to", "be", "been", "have", "that", "and", "is", "deep"]) {
147
+ Object.defineProperty(asserter, key, chainer);
148
+ }
149
+ Object.defineProperty(asserter, "not", { get: () => makeAsserter(value, !negated) });
150
+ Object.defineProperty(asserter, "ok", { get: () => {
151
+ doAssert(Boolean(value), `Expected ${JSON.stringify(value)} to be truthy`);
152
+ return asserter;
153
+ } });
154
+ Object.defineProperty(asserter, "true", { get: () => {
155
+ doAssert(value === true, `Expected ${JSON.stringify(value)} to be true`);
156
+ return asserter;
157
+ } });
158
+ Object.defineProperty(asserter, "false", { get: () => {
159
+ doAssert(value === false, `Expected ${JSON.stringify(value)} to be false`);
160
+ return asserter;
161
+ } });
162
+ Object.defineProperty(asserter, "null", { get: () => {
163
+ doAssert(value === null, `Expected ${JSON.stringify(value)} to be null`);
164
+ return asserter;
165
+ } });
166
+ Object.defineProperty(asserter, "undefined", { get: () => {
167
+ doAssert(value === void 0, `Expected value to be undefined`);
168
+ return asserter;
169
+ } });
170
+ asserter.equal = (expected) => {
171
+ doAssert(
172
+ value === expected,
173
+ `Expected ${JSON.stringify(value)} to ${negated ? "not " : ""}equal ${JSON.stringify(expected)}`
174
+ );
175
+ return asserter;
176
+ };
177
+ asserter.eq = asserter.equal;
178
+ asserter.eql = (expected) => {
179
+ doAssert(
180
+ JSON.stringify(value) === JSON.stringify(expected),
181
+ `Expected deep equal: ${JSON.stringify(value)} ${negated ? "!=" : "=="} ${JSON.stringify(expected)}`
182
+ );
183
+ return asserter;
184
+ };
185
+ asserter.include = (substr) => {
186
+ if (typeof value === "string") {
187
+ doAssert(
188
+ value.includes(String(substr)),
189
+ `Expected "${value}" to ${negated ? "not " : ""}include "${substr}"`
190
+ );
191
+ } else if (Array.isArray(value)) {
192
+ doAssert(
193
+ value.includes(substr),
194
+ `Expected array to ${negated ? "not " : ""}include ${JSON.stringify(substr)}`
195
+ );
196
+ }
197
+ return asserter;
198
+ };
199
+ asserter.contain = asserter.include;
200
+ asserter.property = (name, expected) => {
201
+ doAssert(
202
+ value != null && name in Object(value),
203
+ `Expected object to ${negated ? "not " : ""}have property "${name}"`
204
+ );
205
+ if (expected !== void 0) {
206
+ doAssert(
207
+ value[name] === expected,
208
+ `Expected property "${name}" to equal ${JSON.stringify(expected)}`
209
+ );
210
+ }
211
+ return asserter;
212
+ };
213
+ asserter.a = (type) => {
214
+ const actual = Array.isArray(value) ? "array" : typeof value;
215
+ doAssert(
216
+ actual === type,
217
+ `Expected ${JSON.stringify(value)} to ${negated ? "not " : ""}be a ${type}`
218
+ );
219
+ return asserter;
220
+ };
221
+ asserter.above = (n) => {
222
+ doAssert(value > n, `Expected ${value} to ${negated ? "not " : ""}be above ${n}`);
223
+ return asserter;
224
+ };
225
+ asserter.below = (n) => {
226
+ doAssert(value < n, `Expected ${value} to ${negated ? "not " : ""}be below ${n}`);
227
+ return asserter;
228
+ };
229
+ asserter.least = (n) => {
230
+ doAssert(value >= n, `Expected ${value} to ${negated ? "not " : ""}be at least ${n}`);
231
+ return asserter;
232
+ };
233
+ asserter.most = (n) => {
234
+ doAssert(value <= n, `Expected ${value} to ${negated ? "not " : ""}be at most ${n}`);
235
+ return asserter;
236
+ };
237
+ asserter.gt = asserter.above;
238
+ asserter.gte = asserter.least;
239
+ asserter.lt = asserter.below;
240
+ asserter.lte = asserter.most;
241
+ return asserter;
242
+ }
243
+ class AssertionError extends Error {
244
+ constructor(message) {
245
+ super(message);
246
+ this.name = "AssertionError";
247
+ }
248
+ }
249
+ function buildAt(ctx, testResults, consoleOutput) {
250
+ const { envVars, collectionVars, globals: globals2, localVars } = ctx;
251
+ function makeVarScope(store, scopeName) {
252
+ return {
253
+ get: (key) => store[key] ?? null,
254
+ set: (key, value) => {
255
+ store[key] = String(value);
256
+ consoleOutput.push(`[set] ${scopeName}.${key} = ${JSON.stringify(String(value))}`);
257
+ },
258
+ clear: (key) => {
259
+ delete store[key];
260
+ consoleOutput.push(`[set] ${scopeName}.${key} cleared`);
261
+ },
262
+ has: (key) => key in store,
263
+ toObject: () => ({ ...store })
264
+ };
265
+ }
266
+ const sp = {
267
+ // Variable scopes
268
+ variables: makeVarScope(localVars, "variables"),
269
+ environment: makeVarScope(envVars, "environment"),
270
+ collectionVariables: makeVarScope(collectionVars, "collectionVariables"),
271
+ globals: makeVarScope(globals2, "globals"),
272
+ // Convenience: get/set across all scopes (local wins)
273
+ variables_get: (key) => localVars[key] ?? envVars[key] ?? collectionVars[key] ?? globals2[key] ?? null,
274
+ variables_set: (key, value) => {
275
+ localVars[key] = String(value);
276
+ },
277
+ // Test runner
278
+ test: (name, fn) => {
279
+ try {
280
+ fn();
281
+ testResults.push({ name, passed: true });
282
+ } catch (err) {
283
+ testResults.push({
284
+ name,
285
+ passed: false,
286
+ error: err instanceof Error ? err.message : String(err)
287
+ });
288
+ }
289
+ },
290
+ // Expect / assertions
291
+ expect: (value) => makeAsserter(value, false)
292
+ };
293
+ if (ctx.response) {
294
+ const resp = ctx.response;
295
+ let parsedJson = void 0;
296
+ sp.response = {
297
+ code: resp.status,
298
+ status: `${resp.status} ${resp.statusText}`,
299
+ statusText: resp.statusText,
300
+ responseTime: resp.durationMs,
301
+ responseSize: resp.bodySize,
302
+ headers: {
303
+ get: (name) => resp.headers[name.toLowerCase()] ?? null,
304
+ toObject: () => resp.headers
305
+ },
306
+ json: () => {
307
+ if (parsedJson === void 0) parsedJson = JSON.parse(resp.body);
308
+ return parsedJson;
309
+ },
310
+ text: () => resp.body,
311
+ to: {
312
+ have: {
313
+ status: (code) => {
314
+ if (resp.status !== code) throw new AssertionError(`Expected status ${resp.status} to be ${code}`);
315
+ }
316
+ }
317
+ }
318
+ };
319
+ }
320
+ return sp;
321
+ }
322
+ async function runScript(code, ctx, timeoutMs = 5e3) {
323
+ const faker = await getFaker();
324
+ const testResults = [];
325
+ const consoleOutput = [];
326
+ const envVarsCopy = { ...ctx.envVars };
327
+ const collectionCopy = { ...ctx.collectionVars };
328
+ const globalsCopy = { ...ctx.globals };
329
+ const localVarsCopy = { ...ctx.localVars };
330
+ const scriptCtx = {
331
+ envVars: envVarsCopy,
332
+ collectionVars: collectionCopy,
333
+ globals: globalsCopy,
334
+ localVars: localVarsCopy,
335
+ response: ctx.response
336
+ };
337
+ const sp = buildAt(scriptCtx, testResults, consoleOutput);
338
+ const captureConsole = {
339
+ log: (...args) => consoleOutput.push(args.map(String).join(" ")),
340
+ warn: (...args) => consoleOutput.push("[warn] " + args.map(String).join(" ")),
341
+ error: (...args) => consoleOutput.push("[error] " + args.map(String).join(" ")),
342
+ info: (...args) => consoleOutput.push("[info] " + args.map(String).join(" "))
343
+ };
344
+ const sandbox = {
345
+ sp,
346
+ dayjs,
347
+ faker,
348
+ tv4,
349
+ console: captureConsole,
350
+ JSON,
351
+ Math,
352
+ Date,
353
+ parseInt,
354
+ parseFloat,
355
+ isNaN,
356
+ isFinite,
357
+ encodeURIComponent,
358
+ decodeURIComponent,
359
+ btoa: (s) => Buffer.from(s, "binary").toString("base64"),
360
+ atob: (s) => Buffer.from(s, "base64").toString("binary"),
361
+ setTimeout: void 0,
362
+ // not available in sync vm context
363
+ setInterval: void 0
364
+ };
365
+ try {
366
+ vm__namespace.runInNewContext(code, sandbox, { timeout: timeoutMs, filename: "script.js" });
367
+ } catch (err) {
368
+ const message = err instanceof Error ? err.message : String(err);
369
+ return {
370
+ testResults,
371
+ consoleOutput,
372
+ updatedEnvVars: envVarsCopy,
373
+ updatedCollectionVars: collectionCopy,
374
+ updatedGlobals: globalsCopy,
375
+ updatedLocalVars: localVarsCopy,
376
+ error: message
377
+ };
378
+ }
379
+ return {
380
+ testResults,
381
+ consoleOutput,
382
+ updatedEnvVars: envVarsCopy,
383
+ updatedCollectionVars: collectionCopy,
384
+ updatedGlobals: globalsCopy,
385
+ updatedLocalVars: localVarsCopy
386
+ };
387
+ }
388
+ exports.buildEnvVars = buildEnvVars;
389
+ exports.buildUrl = buildUrl;
390
+ exports.getGlobals = getGlobals;
391
+ exports.getSecret = getSecret;
392
+ exports.interpolate = interpolate;
393
+ exports.loadGlobals = loadGlobals;
394
+ exports.mergeVars = mergeVars;
395
+ exports.patchGlobals = patchGlobals;
396
+ exports.persistGlobals = persistGlobals;
397
+ exports.registerSecretHandlers = registerSecretHandlers;
398
+ exports.runScript = runScript;
399
+ exports.setGlobals = setGlobals;