@umeshindu222/apisnap 1.2.1 → 1.2.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/README.md +99 -5
- package/apisnaprc.schema.json +128 -0
- package/dist/core/runner.js +640 -178
- package/dist/core/runner.js.map +1 -1
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +6 -4
- package/dist/middleware/index.js.map +1 -1
- package/package.json +3 -2
package/dist/core/runner.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
const fs_1 =
|
|
39
|
+
const fs_1 = __importStar(require("fs"));
|
|
40
40
|
const path_1 = __importDefault(require("path"));
|
|
41
41
|
const axios_1 = __importDefault(require("axios"));
|
|
42
42
|
const chalk_1 = __importDefault(require("chalk"));
|
|
@@ -44,6 +44,75 @@ const ora_1 = __importDefault(require("ora"));
|
|
|
44
44
|
const commander_1 = require("commander");
|
|
45
45
|
const program = new commander_1.Command();
|
|
46
46
|
const { version } = require('../../package.json');
|
|
47
|
+
function interpolateEnv(value) {
|
|
48
|
+
if (typeof value === 'string') {
|
|
49
|
+
return value.replace(/\$([A-Z_][A-Z0-9_]*)/g, (match, key) => process.env[key] ?? match);
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
return value.map((item) => interpolateEnv(item));
|
|
53
|
+
}
|
|
54
|
+
if (value && typeof value === 'object') {
|
|
55
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, interpolateEnv(v)]));
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
function globToRegExp(pattern) {
|
|
60
|
+
const escaped = pattern
|
|
61
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
62
|
+
.replace(/\*/g, '.*')
|
|
63
|
+
.replace(/\?/g, '.');
|
|
64
|
+
return new RegExp(`^${escaped}$`);
|
|
65
|
+
}
|
|
66
|
+
function pathMatchesFilter(pathname, pattern) {
|
|
67
|
+
if (pattern.includes('*') || pattern.includes('?')) {
|
|
68
|
+
return globToRegExp(pattern).test(pathname);
|
|
69
|
+
}
|
|
70
|
+
return pathname.includes(pattern);
|
|
71
|
+
}
|
|
72
|
+
function percentile(sorted, p) {
|
|
73
|
+
if (sorted.length === 0)
|
|
74
|
+
return 0;
|
|
75
|
+
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
|
76
|
+
return sorted[Math.max(0, Math.min(index, sorted.length - 1))];
|
|
77
|
+
}
|
|
78
|
+
function parseRetryAfterMs(retryAfterHeader) {
|
|
79
|
+
const raw = Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader;
|
|
80
|
+
if (!raw)
|
|
81
|
+
return 2000;
|
|
82
|
+
const asSeconds = Number.parseInt(raw, 10);
|
|
83
|
+
if (Number.isFinite(asSeconds)) {
|
|
84
|
+
return Math.max(0, asSeconds * 1000);
|
|
85
|
+
}
|
|
86
|
+
const dateMs = Date.parse(raw);
|
|
87
|
+
if (Number.isFinite(dateMs)) {
|
|
88
|
+
return Math.max(0, dateMs - Date.now());
|
|
89
|
+
}
|
|
90
|
+
return 2000;
|
|
91
|
+
}
|
|
92
|
+
function sleep(ms) {
|
|
93
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
94
|
+
}
|
|
95
|
+
function normalizeOpenApiPath(p) {
|
|
96
|
+
return p.replace(/\{([^}]+)\}/g, ':$1');
|
|
97
|
+
}
|
|
98
|
+
function parseOpenApiEndpoints(spec) {
|
|
99
|
+
if (!spec || typeof spec !== 'object' || typeof spec.paths !== 'object') {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const validMethods = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head']);
|
|
103
|
+
const endpoints = [];
|
|
104
|
+
for (const [rawPath, pathItem] of Object.entries(spec.paths)) {
|
|
105
|
+
if (!pathItem || typeof pathItem !== 'object')
|
|
106
|
+
continue;
|
|
107
|
+
const methods = Object.keys(pathItem)
|
|
108
|
+
.filter((method) => validMethods.has(method.toLowerCase()))
|
|
109
|
+
.map((method) => method.toUpperCase());
|
|
110
|
+
if (methods.length > 0) {
|
|
111
|
+
endpoints.push({ path: normalizeOpenApiPath(rawPath), methods });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return endpoints;
|
|
115
|
+
}
|
|
47
116
|
// ─── Config File Loader ───────────────────────────────────────────────────────
|
|
48
117
|
function loadConfigFile(env) {
|
|
49
118
|
const configNames = ['.apisnaprc', '.apisnaprc.json', 'apisnap.config.json'];
|
|
@@ -51,16 +120,17 @@ function loadConfigFile(env) {
|
|
|
51
120
|
const filePath = path_1.default.resolve(process.cwd(), name);
|
|
52
121
|
if (fs_1.default.existsSync(filePath)) {
|
|
53
122
|
try {
|
|
54
|
-
// Strip BOM if present (fixes Windows PowerShell encoding issue)
|
|
55
123
|
let raw = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
56
|
-
raw = raw.replace(/^\uFEFF/, '');
|
|
57
|
-
raw = raw.trim();
|
|
124
|
+
raw = raw.replace(/^\uFEFF/, '').trim();
|
|
58
125
|
const config = JSON.parse(raw);
|
|
59
126
|
console.log(chalk_1.default.gray(` Config: ${name}${env ? ` (env: ${env})` : ''}\n`));
|
|
127
|
+
const envMerged = env && config.envs?.[env]
|
|
128
|
+
? { ...config, ...config.envs[env] }
|
|
129
|
+
: config;
|
|
60
130
|
if (env && config.envs?.[env]) {
|
|
61
|
-
return
|
|
131
|
+
return interpolateEnv(envMerged);
|
|
62
132
|
}
|
|
63
|
-
return
|
|
133
|
+
return interpolateEnv(envMerged);
|
|
64
134
|
}
|
|
65
135
|
catch (e) {
|
|
66
136
|
console.warn(chalk_1.default.yellow(`⚠️ Could not parse config file: ${name}`));
|
|
@@ -89,16 +159,23 @@ function validateConfig(config) {
|
|
|
89
159
|
if (config.envs && (typeof config.envs !== 'object' || Array.isArray(config.envs) || config.envs === null)) {
|
|
90
160
|
errors.push({ field: 'envs', message: '"envs" must be an object', fix: '"envs": {"staging": {"baseUrl": "https://staging.example.com"}}' });
|
|
91
161
|
}
|
|
162
|
+
if (config.authFlow) {
|
|
163
|
+
const af = config.authFlow;
|
|
164
|
+
if (!af.url)
|
|
165
|
+
errors.push({ field: 'authFlow.url', message: '"authFlow.url" is required', fix: '"authFlow": { "url": "/auth/login", ... }' });
|
|
166
|
+
if (!af.body)
|
|
167
|
+
errors.push({ field: 'authFlow.body', message: '"authFlow.body" is required', fix: '"authFlow": { "body": { "username": "test", "password": "pass" } }' });
|
|
168
|
+
if (!af.tokenPath)
|
|
169
|
+
errors.push({ field: 'authFlow.tokenPath', message: '"authFlow.tokenPath" is required', fix: '"authFlow": { "tokenPath": "token" }' });
|
|
170
|
+
}
|
|
92
171
|
return errors;
|
|
93
172
|
}
|
|
94
173
|
function parseIntOption(value, name, defaultValue, options = {}) {
|
|
95
|
-
if (value === undefined || value === null || value === '')
|
|
174
|
+
if (value === undefined || value === null || value === '')
|
|
96
175
|
return defaultValue;
|
|
97
|
-
}
|
|
98
176
|
const numericValue = typeof value === 'number' ? value : Number(String(value));
|
|
99
177
|
if (!Number.isFinite(numericValue) || !Number.isInteger(numericValue)) {
|
|
100
178
|
console.error(chalk_1.default.red(`\n ✖ Invalid value for --${name}: "${value}" must be a whole number.`));
|
|
101
|
-
console.error(chalk_1.default.gray(` Example: --${name} ${defaultValue}\n`));
|
|
102
179
|
process.exit(1);
|
|
103
180
|
}
|
|
104
181
|
if (options.min !== undefined && numericValue < options.min) {
|
|
@@ -111,15 +188,13 @@ function parseIntOption(value, name, defaultValue, options = {}) {
|
|
|
111
188
|
}
|
|
112
189
|
return numericValue;
|
|
113
190
|
}
|
|
114
|
-
// ─── Header Parser
|
|
191
|
+
// ─── Header Parser ────────────────────────────────────────────────────────
|
|
115
192
|
function parseHeaders(headerArgs) {
|
|
116
193
|
const headers = {};
|
|
117
194
|
for (const h of headerArgs) {
|
|
118
195
|
const colonIdx = h.indexOf(':');
|
|
119
196
|
if (colonIdx > 0) {
|
|
120
|
-
|
|
121
|
-
const value = h.slice(colonIdx + 1).trim();
|
|
122
|
-
headers[key] = value;
|
|
197
|
+
headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
|
|
123
198
|
}
|
|
124
199
|
else {
|
|
125
200
|
console.warn(chalk_1.default.yellow(`⚠️ Skipping malformed header: "${h}" (expected "Key: Value")`));
|
|
@@ -127,12 +202,78 @@ function parseHeaders(headerArgs) {
|
|
|
127
202
|
}
|
|
128
203
|
return headers;
|
|
129
204
|
}
|
|
205
|
+
// ─── Dot-path resolver (for token extraction) ────────────────────────────────
|
|
206
|
+
function resolveDotPath(obj, dotPath) {
|
|
207
|
+
const parts = dotPath.split('.');
|
|
208
|
+
let current = obj;
|
|
209
|
+
for (const part of parts) {
|
|
210
|
+
if (current == null || typeof current !== 'object')
|
|
211
|
+
return null;
|
|
212
|
+
current = current[part];
|
|
213
|
+
}
|
|
214
|
+
return typeof current === 'string' ? current : (current != null ? String(current) : null);
|
|
215
|
+
}
|
|
216
|
+
// ─── Auth Flow Executor ───────────────────────────────────────────────────────
|
|
217
|
+
async function executeAuthFlow(authFlow, baseUrl, timeout) {
|
|
218
|
+
const authSpinner = (0, ora_1.default)(' Authenticating via auth flow...').start();
|
|
219
|
+
try {
|
|
220
|
+
const loginUrl = authFlow.url.startsWith('http') ? authFlow.url : `${baseUrl}${authFlow.url}`;
|
|
221
|
+
const res = await axios_1.default.post(loginUrl, authFlow.body, {
|
|
222
|
+
timeout,
|
|
223
|
+
validateStatus: () => true,
|
|
224
|
+
headers: { 'Content-Type': 'application/json' },
|
|
225
|
+
});
|
|
226
|
+
if (res.status >= 400) {
|
|
227
|
+
authSpinner.fail(chalk_1.default.red(`Auth flow failed: ${res.status} ${res.statusText}`));
|
|
228
|
+
console.log(chalk_1.default.yellow(` Hint: Check authFlow.body credentials and authFlow.url in your config.`));
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const token = resolveDotPath(res.data, authFlow.tokenPath);
|
|
232
|
+
if (!token) {
|
|
233
|
+
authSpinner.fail(chalk_1.default.red(`Auth flow: could not find token at path "${authFlow.tokenPath}" in response`));
|
|
234
|
+
console.log(chalk_1.default.gray(` Response body: ${JSON.stringify(res.data).slice(0, 200)}`));
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
const headerName = authFlow.headerName || 'Authorization';
|
|
238
|
+
const prefix = authFlow.prefix !== undefined ? authFlow.prefix : 'Bearer ';
|
|
239
|
+
const headerValue = `${prefix}${token}`;
|
|
240
|
+
authSpinner.succeed(chalk_1.default.green(`Auth flow succeeded → injecting ${headerName}: ${prefix}${token.slice(0, 8)}••••••`));
|
|
241
|
+
return { headerName, headerValue };
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
authSpinner.fail(chalk_1.default.red(`Auth flow error: ${err.message}`));
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// ─── Cookie Jar (session auth) ───────────────────────────────────────────────
|
|
249
|
+
class CookieJar {
|
|
250
|
+
constructor() {
|
|
251
|
+
this.cookies = new Map();
|
|
252
|
+
}
|
|
253
|
+
ingest(setCookieHeaders) {
|
|
254
|
+
if (!setCookieHeaders)
|
|
255
|
+
return;
|
|
256
|
+
const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
|
|
257
|
+
for (const header of headers) {
|
|
258
|
+
const [kv] = header.split(';');
|
|
259
|
+
const idx = kv.indexOf('=');
|
|
260
|
+
if (idx > 0) {
|
|
261
|
+
this.cookies.set(kv.slice(0, idx).trim(), kv.slice(idx + 1).trim());
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
toString() {
|
|
266
|
+
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
267
|
+
}
|
|
268
|
+
has() {
|
|
269
|
+
return this.cookies.size > 0;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
130
272
|
// ─── Smart Path Param Replacement ────────────────────────────────────────────
|
|
131
273
|
function replacePath(rawPath, paramMap = {}) {
|
|
132
274
|
return rawPath.replace(/:([a-zA-Z0-9_]+)/g, (_, param) => {
|
|
133
275
|
if (paramMap[param])
|
|
134
276
|
return paramMap[param];
|
|
135
|
-
// Smart defaults based on param name
|
|
136
277
|
if (/id$/i.test(param))
|
|
137
278
|
return '1';
|
|
138
279
|
if (/slug$/i.test(param))
|
|
@@ -147,9 +288,13 @@ function replacePath(rawPath, paramMap = {}) {
|
|
|
147
288
|
return '1';
|
|
148
289
|
if (/limit$/i.test(param))
|
|
149
290
|
return '10';
|
|
150
|
-
return '1';
|
|
291
|
+
return '1';
|
|
151
292
|
});
|
|
152
293
|
}
|
|
294
|
+
// ─── Exponential Backoff ──────────────────────────────────────────────────────
|
|
295
|
+
function backoffDelay(attempt, baseMs = 300) {
|
|
296
|
+
return Math.min(baseMs * Math.pow(2, attempt) + Math.random() * 100, 10000);
|
|
297
|
+
}
|
|
153
298
|
// ─── Concurrency Limiter ──────────────────────────────────────────────────────
|
|
154
299
|
async function runWithConcurrency(tasks, limit) {
|
|
155
300
|
const results = Array.from({ length: tasks.length }, () => new Error('Task never executed'));
|
|
@@ -165,13 +310,86 @@ async function runWithConcurrency(tasks, limit) {
|
|
|
165
310
|
}
|
|
166
311
|
}
|
|
167
312
|
}
|
|
168
|
-
// Spin up `limit` workers — each pulls the next task when free
|
|
169
313
|
const workers = Array.from({ length: Math.min(limit, tasks.length) }, worker);
|
|
170
314
|
await Promise.all(workers);
|
|
171
315
|
return results;
|
|
172
316
|
}
|
|
317
|
+
// ─── Baseline Diff Engine ─────────────────────────────────────────────────────
|
|
318
|
+
function diffAgainstBaseline(current, baselinePath) {
|
|
319
|
+
if (!fs_1.default.existsSync(baselinePath))
|
|
320
|
+
return null;
|
|
321
|
+
let baseline;
|
|
322
|
+
try {
|
|
323
|
+
baseline = JSON.parse(fs_1.default.readFileSync(baselinePath, 'utf-8'));
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
console.warn(chalk_1.default.yellow(`⚠️ Could not parse baseline file: ${baselinePath}`));
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const baselineMap = new Map(baseline.results.map(r => [`${r.method}:${r.path}`, r]));
|
|
330
|
+
const currentMap = new Map(current.results.map(r => [`${r.method}:${r.path}`, r]));
|
|
331
|
+
const regressions = [];
|
|
332
|
+
const improvements = [];
|
|
333
|
+
let unchanged = 0;
|
|
334
|
+
const baselineKeys = new Set(baselineMap.keys());
|
|
335
|
+
const currentKeys = new Set(currentMap.keys());
|
|
336
|
+
const newEndpoints = [...currentKeys].filter(k => !baselineKeys.has(k));
|
|
337
|
+
const removedEndpoints = [...baselineKeys].filter(k => !currentKeys.has(k));
|
|
338
|
+
for (const [key, curr] of currentMap) {
|
|
339
|
+
const prev = baselineMap.get(key);
|
|
340
|
+
if (!prev)
|
|
341
|
+
continue;
|
|
342
|
+
const wasOk = prev.success;
|
|
343
|
+
const isOk = curr.success;
|
|
344
|
+
const wasSlow = prev.slow;
|
|
345
|
+
const isSlow = curr.slow;
|
|
346
|
+
if (wasOk && !isOk) {
|
|
347
|
+
regressions.push({ path: curr.path, method: curr.method, prev: { status: prev.status, duration: prev.duration }, curr: { status: curr.status, duration: curr.duration, error: curr.error }, reason: `Status changed ${prev.status} → ${curr.status}` });
|
|
348
|
+
}
|
|
349
|
+
else if (!wasOk && isOk) {
|
|
350
|
+
improvements.push({ path: curr.path, method: curr.method, reason: `Fixed: ${prev.status} → ${curr.status}` });
|
|
351
|
+
}
|
|
352
|
+
else if (isOk && !wasSlow && isSlow) {
|
|
353
|
+
regressions.push({ path: curr.path, method: curr.method, prev: { duration: prev.duration }, curr: { duration: curr.duration }, reason: `Latency spike: ${prev.duration}ms → ${curr.duration}ms` });
|
|
354
|
+
}
|
|
355
|
+
else if (isOk && wasSlow && !isSlow) {
|
|
356
|
+
improvements.push({ path: curr.path, method: curr.method, reason: `Faster: ${prev.duration}ms → ${curr.duration}ms` });
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
unchanged++;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { regressions, improvements, unchanged, newEndpoints, removedEndpoints };
|
|
363
|
+
}
|
|
364
|
+
function printDiffReport(diff) {
|
|
365
|
+
console.log(chalk_1.default.bold('\n🔍 Regression Diff:'));
|
|
366
|
+
if (diff.regressions.length === 0 && diff.improvements.length === 0) {
|
|
367
|
+
console.log(chalk_1.default.green(' ✅ No regressions — results match baseline.'));
|
|
368
|
+
}
|
|
369
|
+
if (diff.regressions.length > 0) {
|
|
370
|
+
console.log(chalk_1.default.red.bold(`\n ⛔ ${diff.regressions.length} regression(s):`));
|
|
371
|
+
for (const r of diff.regressions) {
|
|
372
|
+
console.log(chalk_1.default.red(` ✖ [${r.method}] ${r.path}`));
|
|
373
|
+
console.log(chalk_1.default.gray(` ${r.reason}`));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (diff.improvements.length > 0) {
|
|
377
|
+
console.log(chalk_1.default.green.bold(`\n 🎉 ${diff.improvements.length} improvement(s):`));
|
|
378
|
+
for (const i of diff.improvements) {
|
|
379
|
+
console.log(chalk_1.default.green(` ✔ [${i.method}] ${i.path}`));
|
|
380
|
+
console.log(chalk_1.default.gray(` ${i.reason}`));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (diff.newEndpoints.length > 0) {
|
|
384
|
+
console.log(chalk_1.default.cyan(`\n 🆕 ${diff.newEndpoints.length} new endpoint(s): ${diff.newEndpoints.join(', ')}`));
|
|
385
|
+
}
|
|
386
|
+
if (diff.removedEndpoints.length > 0) {
|
|
387
|
+
console.log(chalk_1.default.yellow(`\n 🗑 ${diff.removedEndpoints.length} removed endpoint(s): ${diff.removedEndpoints.join(', ')}`));
|
|
388
|
+
}
|
|
389
|
+
console.log(chalk_1.default.gray(`\n Unchanged: ${diff.unchanged} endpoint(s)`));
|
|
390
|
+
}
|
|
173
391
|
// ─── HTML Report Generator ────────────────────────────────────────────────────
|
|
174
|
-
function
|
|
392
|
+
function writeHTMLReport(filePath, data, diff) {
|
|
175
393
|
const passRate = data.summary.total > 0
|
|
176
394
|
? Math.round((data.summary.passed / data.summary.total) * 100)
|
|
177
395
|
: 0;
|
|
@@ -182,19 +400,24 @@ function generateHTMLReport(data) {
|
|
|
182
400
|
return '#fef9c3';
|
|
183
401
|
return '#f0fdf4';
|
|
184
402
|
};
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
<
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
403
|
+
const diffSection = diff ? `
|
|
404
|
+
<div class="diff-section">
|
|
405
|
+
<h2>🔍 Regression Diff</h2>
|
|
406
|
+
${diff.regressions.length === 0 && diff.improvements.length === 0
|
|
407
|
+
? '<p class="no-regressions">✅ No regressions — results match baseline.</p>'
|
|
408
|
+
: ''}
|
|
409
|
+
${diff.regressions.length > 0 ? `
|
|
410
|
+
<h3 class="red">⛔ ${diff.regressions.length} Regression(s)</h3>
|
|
411
|
+
<ul>${diff.regressions.map(r => `<li><strong>[${r.method}] ${r.path}</strong> — ${r.reason}</li>`).join('')}</ul>
|
|
412
|
+
` : ''}
|
|
413
|
+
${diff.improvements.length > 0 ? `
|
|
414
|
+
<h3 class="green">🎉 ${diff.improvements.length} Improvement(s)</h3>
|
|
415
|
+
<ul>${diff.improvements.map(i => `<li><strong>[${i.method}] ${i.path}</strong> — ${i.reason}</li>`).join('')}</ul>
|
|
416
|
+
` : ''}
|
|
417
|
+
${diff.newEndpoints.length > 0 ? `<p class="blue">🆕 New: ${diff.newEndpoints.join(', ')}</p>` : ''}
|
|
418
|
+
${diff.removedEndpoints.length > 0 ? `<p class="yellow">🗑 Removed: ${diff.removedEndpoints.join(', ')}</p>` : ''}
|
|
419
|
+
</div>` : '';
|
|
420
|
+
const head = `<!DOCTYPE html>
|
|
198
421
|
<html lang="en">
|
|
199
422
|
<head>
|
|
200
423
|
<meta charset="UTF-8"/>
|
|
@@ -203,6 +426,8 @@ function generateHTMLReport(data) {
|
|
|
203
426
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
204
427
|
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f8fafc;color:#1e293b;padding:2rem}
|
|
205
428
|
h1{font-size:1.8rem;margin-bottom:.25rem}
|
|
429
|
+
h2{font-size:1.2rem;margin:1.5rem 0 .75rem}
|
|
430
|
+
h3{font-size:1rem;margin:.75rem 0 .4rem}
|
|
206
431
|
.sub{color:#64748b;font-size:.9rem;margin-bottom:2rem}
|
|
207
432
|
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem;margin-bottom:2rem}
|
|
208
433
|
.card{background:#fff;border-radius:12px;padding:1.2rem;box-shadow:0 1px 4px rgba(0,0,0,.08);text-align:center}
|
|
@@ -217,8 +442,13 @@ function generateHTMLReport(data) {
|
|
|
217
442
|
.badge-delete{background:#dc2626}.badge-patch{background:#7c3aed}
|
|
218
443
|
.ok{color:#16a34a;font-weight:600}.fail{color:#dc2626;font-weight:600}.slow{color:#ca8a04;font-weight:600}
|
|
219
444
|
.errtext{color:#dc2626;font-size:.8rem}
|
|
445
|
+
.auth-tag{background:#e0e7ff;color:#3730a3;border-radius:4px;padding:.1rem .4rem;font-size:.75rem;font-weight:600}
|
|
220
446
|
.progress{background:#e2e8f0;border-radius:999px;height:10px;margin:1rem 0}
|
|
221
447
|
.progress-bar{background:#16a34a;height:10px;border-radius:999px;transition:width .3s}
|
|
448
|
+
.diff-section{background:#fff;border-radius:12px;padding:1.5rem;margin-top:2rem;box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
|
449
|
+
.diff-section ul{padding-left:1.25rem;margin:.5rem 0}
|
|
450
|
+
.diff-section li{margin:.3rem 0;font-size:.9rem}
|
|
451
|
+
.no-regressions{color:#16a34a;font-weight:600}
|
|
222
452
|
footer{margin-top:2rem;color:#94a3b8;font-size:.8rem;text-align:center}
|
|
223
453
|
</style>
|
|
224
454
|
</head>
|
|
@@ -232,6 +462,9 @@ function generateHTMLReport(data) {
|
|
|
232
462
|
<div class="card"><div class="num red">${data.summary.failed}</div><div class="lbl">Failed</div></div>
|
|
233
463
|
<div class="card"><div class="num yellow">${data.summary.slow}</div><div class="lbl">Slow (>${data.config.slowThreshold}ms)</div></div>
|
|
234
464
|
<div class="card"><div class="num blue">${data.summary.avgDuration}ms</div><div class="lbl">Avg Response</div></div>
|
|
465
|
+
<div class="card"><div class="num blue">${data.summary.p50Duration}ms</div><div class="lbl">p50</div></div>
|
|
466
|
+
<div class="card"><div class="num blue">${data.summary.p95Duration}ms</div><div class="lbl">p95</div></div>
|
|
467
|
+
<div class="card"><div class="num blue">${data.summary.p99Duration}ms</div><div class="lbl">p99</div></div>
|
|
235
468
|
<div class="card"><div class="num ${passRate === 100 ? 'green' : passRate >= 80 ? 'yellow' : 'red'}">${passRate}%</div><div class="lbl">Pass Rate</div></div>
|
|
236
469
|
</div>
|
|
237
470
|
|
|
@@ -239,14 +472,38 @@ function generateHTMLReport(data) {
|
|
|
239
472
|
|
|
240
473
|
<table>
|
|
241
474
|
<thead>
|
|
242
|
-
<tr><th>Method</th><th>Path</th><th>Status</th><th>Duration</th><th>Retries</th><th>Error</th></tr>
|
|
475
|
+
<tr><th>Method</th><th>Path</th><th>Status</th><th>Duration</th><th>Retries</th><th>Auth</th><th>Error</th></tr>
|
|
243
476
|
</thead>
|
|
244
|
-
|
|
477
|
+
<tbody>`;
|
|
478
|
+
const foot = `</tbody>
|
|
245
479
|
</table>
|
|
246
480
|
|
|
481
|
+
${diffSection}
|
|
482
|
+
|
|
247
483
|
<footer>APISnap v${data.version} — MIT License</footer>
|
|
248
484
|
</body>
|
|
249
485
|
</html>`;
|
|
486
|
+
return new Promise((resolve, reject) => {
|
|
487
|
+
const out = (0, fs_1.createWriteStream)(filePath, { encoding: 'utf8' });
|
|
488
|
+
out.on('error', reject);
|
|
489
|
+
out.on('finish', resolve);
|
|
490
|
+
out.write(head);
|
|
491
|
+
for (const r of data.results) {
|
|
492
|
+
out.write(`
|
|
493
|
+
<tr style="background:${rowColor(r)}">
|
|
494
|
+
<td><span class="badge badge-${r.method.toLowerCase()}">${r.method}</span></td>
|
|
495
|
+
<td><code>${r.path}</code></td>
|
|
496
|
+
<td>${r.success
|
|
497
|
+
? `<span class="ok">✔ ${r.status}</span>`
|
|
498
|
+
: `<span class="fail">✖ ${r.status || 'ERR'}</span>`}</td>
|
|
499
|
+
<td>${r.slow ? `<span class="slow">⚠️ ${r.duration}ms</span>` : `${r.duration}ms`}</td>
|
|
500
|
+
<td>${r.retries > 0 ? `${r.retries} retry` : '—'}</td>
|
|
501
|
+
<td>${r.authMethod ? `<span class="auth-tag">${r.authMethod}</span>` : '—'}</td>
|
|
502
|
+
<td>${r.error ? `<span class="errtext">${r.error}</span>` : '—'}</td>
|
|
503
|
+
</tr>`);
|
|
504
|
+
}
|
|
505
|
+
out.end(foot);
|
|
506
|
+
});
|
|
250
507
|
}
|
|
251
508
|
// ─── Main CLI ─────────────────────────────────────────────────────────────────
|
|
252
509
|
program
|
|
@@ -260,7 +517,16 @@ program
|
|
|
260
517
|
console.log(chalk_1.default.gray(' This creates a .apisnaprc.json in your project root.\n'));
|
|
261
518
|
const port = (await q(chalk_1.default.white(' Server port? ') + chalk_1.default.gray('[3000]: '))).trim() || '3000';
|
|
262
519
|
const slowInput = (await q(chalk_1.default.white(' Slow threshold (ms)? ') + chalk_1.default.gray('[200]: '))).trim() || '200';
|
|
263
|
-
const authRaw = await q(chalk_1.default.white('
|
|
520
|
+
const authRaw = await q(chalk_1.default.white(' Static auth token? ') + chalk_1.default.gray('(leave blank to skip): '));
|
|
521
|
+
const useAuthFlow = (await q(chalk_1.default.white(' Set up auth flow (auto-login)? ') + chalk_1.default.gray('[y/N]: '))).trim().toLowerCase() === 'y';
|
|
522
|
+
let authFlowConfig = null;
|
|
523
|
+
if (useAuthFlow) {
|
|
524
|
+
const loginUrl = (await q(chalk_1.default.white(' Login endpoint? ') + chalk_1.default.gray('[/auth/login]: '))).trim() || '/auth/login';
|
|
525
|
+
const username = (await q(chalk_1.default.white(' Username/email field value: '))).trim();
|
|
526
|
+
const password = (await q(chalk_1.default.white(' Password field value: '))).trim();
|
|
527
|
+
const tokenPath = (await q(chalk_1.default.white(' Token path in response? ') + chalk_1.default.gray('[token]: '))).trim() || 'token';
|
|
528
|
+
authFlowConfig = { url: loginUrl, body: { username, password }, tokenPath };
|
|
529
|
+
}
|
|
264
530
|
const skipRaw = await q(chalk_1.default.white(' Paths to skip? ') + chalk_1.default.gray('e.g. /admin,/internal (or blank): '));
|
|
265
531
|
const configPath = path_1.default.resolve(process.cwd(), '.apisnaprc.json');
|
|
266
532
|
const alreadyExists = fs_1.default.existsSync(configPath);
|
|
@@ -278,39 +544,109 @@ program
|
|
|
278
544
|
port,
|
|
279
545
|
slow: Number.isNaN(parsedSlow) ? 200 : parsedSlow,
|
|
280
546
|
};
|
|
281
|
-
if (authRaw.trim())
|
|
547
|
+
if (authRaw.trim())
|
|
282
548
|
config.headers = [`Authorization: Bearer ${authRaw.trim()}`];
|
|
283
|
-
|
|
284
|
-
|
|
549
|
+
if (authFlowConfig)
|
|
550
|
+
config.authFlow = authFlowConfig;
|
|
551
|
+
if (skipRaw.trim())
|
|
285
552
|
config.skip = skipRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
286
|
-
}
|
|
287
553
|
fs_1.default.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
288
554
|
console.log(chalk_1.default.green('\n ✅ Created .apisnaprc.json\n'));
|
|
289
555
|
console.log(chalk_1.default.gray(' Next steps:'));
|
|
290
556
|
console.log(chalk_1.default.cyan(' 1. Start your server'));
|
|
291
557
|
console.log(chalk_1.default.cyan(' 2. Run: apisnap\n'));
|
|
292
558
|
});
|
|
559
|
+
program
|
|
560
|
+
.command('doctor')
|
|
561
|
+
.description('Diagnose your APISnap setup')
|
|
562
|
+
.option('-p, --port <number>', 'Port your server is running on')
|
|
563
|
+
.option('--env <name>', 'Use environment profile from config')
|
|
564
|
+
.action(async (options) => {
|
|
565
|
+
const fileConfig = loadConfigFile(options.env);
|
|
566
|
+
const port = options.port || fileConfig.port || '3000';
|
|
567
|
+
const discoveryUrl = `http://localhost:${port}/__apisnap_discovery`;
|
|
568
|
+
const checks = [
|
|
569
|
+
{
|
|
570
|
+
name: 'Config valid',
|
|
571
|
+
run: async () => validateConfig(fileConfig).length === 0,
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
name: 'Server reachable',
|
|
575
|
+
run: async () => {
|
|
576
|
+
const res = await axios_1.default.get(discoveryUrl, { timeout: 2000, validateStatus: () => true });
|
|
577
|
+
return res.status < 500;
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: 'Middleware installed',
|
|
582
|
+
run: async () => {
|
|
583
|
+
const res = await axios_1.default.get(discoveryUrl, { timeout: 2000, validateStatus: () => true });
|
|
584
|
+
return res.status === 200 && res.data?.tool === 'APISnap';
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
];
|
|
588
|
+
if (fileConfig.authFlow) {
|
|
589
|
+
checks.push({
|
|
590
|
+
name: 'Auth flow works',
|
|
591
|
+
run: async () => {
|
|
592
|
+
const baseUrl = fileConfig.baseUrl || `http://localhost:${port}`;
|
|
593
|
+
const timeout = parseIntOption(fileConfig.timeout, 'timeout', 5000, { min: 100 });
|
|
594
|
+
const authResult = await executeAuthFlow(fileConfig.authFlow, baseUrl, timeout);
|
|
595
|
+
return !!authResult;
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
let failedChecks = 0;
|
|
600
|
+
console.log(chalk_1.default.bold('\n🩺 APISnap Doctor\n'));
|
|
601
|
+
for (const check of checks) {
|
|
602
|
+
try {
|
|
603
|
+
const ok = await check.run();
|
|
604
|
+
if (ok) {
|
|
605
|
+
console.log(chalk_1.default.green(` ✅ ${check.name}`));
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
failedChecks++;
|
|
609
|
+
console.log(chalk_1.default.red(` ❌ ${check.name}`));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
failedChecks++;
|
|
614
|
+
console.log(chalk_1.default.red(` ❌ ${check.name}`));
|
|
615
|
+
console.log(chalk_1.default.gray(` ${error.message}`));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
console.log();
|
|
619
|
+
process.exit(failedChecks > 0 ? 1 : 0);
|
|
620
|
+
});
|
|
293
621
|
program
|
|
294
622
|
.name('apisnap')
|
|
295
623
|
.description('Instant API health-check CLI for Express.js')
|
|
296
624
|
.version(version)
|
|
297
625
|
.option('-p, --port <number>', 'Port your server is running on')
|
|
298
|
-
.option('-H, --header <string>', 'Custom header
|
|
626
|
+
.option('-H, --header <string>', 'Custom header (repeatable)', collect, [])
|
|
299
627
|
.option('-c, --cookie <string>', 'Cookie string (e.g. "sessionId=abc; token=xyz")')
|
|
300
628
|
.option('-s, --slow <number>', 'Slow response threshold in ms')
|
|
301
629
|
.option('-t, --timeout <number>', 'Request timeout in ms')
|
|
302
|
-
.option('-r, --retry <number>', 'Retry failed requests N times')
|
|
303
|
-
.option('-e, --export <filename>', 'Export JSON report
|
|
304
|
-
.option('--html <filename>', 'Export HTML report
|
|
630
|
+
.option('-r, --retry <number>', 'Retry failed requests N times (uses exponential backoff)')
|
|
631
|
+
.option('-e, --export <filename>', 'Export JSON report')
|
|
632
|
+
.option('--html <filename>', 'Export HTML report')
|
|
305
633
|
.option('--only <methods>', 'Only test specific methods (e.g. "GET,POST")')
|
|
306
|
-
.option('--env <name>', 'Use environment profile from config
|
|
307
|
-
.option('--base-url <url>', 'Override base URL
|
|
308
|
-
.option('--params <json>', 'JSON map of param overrides
|
|
634
|
+
.option('--env <name>', 'Use environment profile from config')
|
|
635
|
+
.option('--base-url <url>', 'Override base URL')
|
|
636
|
+
.option('--params <json>', 'JSON map of param overrides')
|
|
637
|
+
.option('--filter <pattern>', 'Only test paths matching pattern (glob or substring)')
|
|
638
|
+
.option('--dry-run', 'Preview endpoints and config without sending requests')
|
|
639
|
+
.option('--watch', 'Re-run checks when project files change')
|
|
640
|
+
.option('--openapi <file>', 'Use OpenAPI JSON file for route discovery')
|
|
309
641
|
.option('--fail-on-slow', 'Exit with code 1 if any slow routes are found')
|
|
310
642
|
.option('--concurrency <number>', 'How many requests to run in parallel (default: 1)')
|
|
311
|
-
.option('--body <json>', 'Default JSON body for POST/PUT/PATCH requests
|
|
643
|
+
.option('--body <json>', 'Default JSON body for POST/PUT/PATCH requests')
|
|
644
|
+
.option('--auth-flow', 'Execute auth flow from config to obtain token automatically')
|
|
645
|
+
.option('--save-baseline <filename>', 'Save results as baseline for future diffs (e.g. baseline)')
|
|
646
|
+
.option('--diff <filename>', 'Diff current results against a saved baseline (e.g. baseline.json)')
|
|
647
|
+
.option('--ci', 'CI mode: structured JSON to stdout, strict exit codes, no spinners')
|
|
648
|
+
.option('--session', 'Enable cookie jar — capture Set-Cookie from login and replay on requests')
|
|
312
649
|
.action(async (options) => {
|
|
313
|
-
// Merge config file with CLI options (CLI takes precedence)
|
|
314
650
|
const fileConfig = loadConfigFile(options.env);
|
|
315
651
|
const configErrors = validateConfig(fileConfig);
|
|
316
652
|
if (configErrors.length > 0) {
|
|
@@ -322,27 +658,31 @@ program
|
|
|
322
658
|
process.exit(1);
|
|
323
659
|
}
|
|
324
660
|
const mergedOptions = { ...fileConfig, ...options };
|
|
661
|
+
const ciMode = !!mergedOptions.ci;
|
|
325
662
|
const port = mergedOptions.port || '3000';
|
|
326
663
|
const slowThreshold = parseIntOption(mergedOptions.slow, 'slow', 200, { min: 1 });
|
|
327
664
|
const timeout = parseIntOption(mergedOptions.timeout, 'timeout', 5000, { min: 100 });
|
|
328
665
|
const retryCount = parseIntOption(mergedOptions.retry, 'retry', 0, { min: 0, max: 10 });
|
|
666
|
+
const concurrency = parseIntOption(mergedOptions.concurrency, 'concurrency', 1, { min: 1, max: 50 });
|
|
329
667
|
const onlyMethods = mergedOptions.only
|
|
330
668
|
? mergedOptions.only.split(',').map((m) => m.trim().toUpperCase())
|
|
331
669
|
: null;
|
|
332
|
-
const
|
|
333
|
-
? (typeof mergedOptions.params === 'string'
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
670
|
+
const cliParams = mergedOptions.params
|
|
671
|
+
? (typeof mergedOptions.params === 'string' ? JSON.parse(mergedOptions.params) : mergedOptions.params)
|
|
672
|
+
: {};
|
|
673
|
+
const paramOverrides = {
|
|
674
|
+
...(fileConfig.params || {}),
|
|
675
|
+
...(cliParams || {}),
|
|
676
|
+
};
|
|
338
677
|
const defaultBody = mergedOptions.body
|
|
339
|
-
? (typeof mergedOptions.body === 'string'
|
|
340
|
-
? JSON.parse(mergedOptions.body)
|
|
341
|
-
: mergedOptions.body)
|
|
678
|
+
? (typeof mergedOptions.body === 'string' ? JSON.parse(mergedOptions.body) : mergedOptions.body)
|
|
342
679
|
: (fileConfig.body || null);
|
|
343
680
|
const baseUrl = mergedOptions.baseUrl || mergedOptions['base-url'] || `http://localhost:${port}`;
|
|
344
681
|
const discoveryUrl = `http://localhost:${port}/__apisnap_discovery`;
|
|
345
|
-
|
|
682
|
+
const useAuthFlow = !!(mergedOptions.authFlow || mergedOptions['auth-flow']);
|
|
683
|
+
const useSession = !!mergedOptions.session;
|
|
684
|
+
const watchMode = !!mergedOptions.watch;
|
|
685
|
+
let cachedEndpoints = null;
|
|
346
686
|
const headerArgs = [
|
|
347
687
|
...(Array.isArray(mergedOptions.header) ? mergedOptions.header : []),
|
|
348
688
|
...(Array.isArray(fileConfig.headers) ? fileConfig.headers : []),
|
|
@@ -351,65 +691,131 @@ program
|
|
|
351
691
|
...parseHeaders(headerArgs),
|
|
352
692
|
'User-Agent': `APISnap/${version}`,
|
|
353
693
|
};
|
|
354
|
-
if (mergedOptions.cookie)
|
|
694
|
+
if (mergedOptions.cookie)
|
|
355
695
|
customHeaders['Cookie'] = mergedOptions.cookie;
|
|
696
|
+
let authMethod;
|
|
697
|
+
const cookieJar = new CookieJar();
|
|
698
|
+
if (useAuthFlow && fileConfig.authFlow) {
|
|
699
|
+
const authResult = await executeAuthFlow(fileConfig.authFlow, baseUrl, timeout);
|
|
700
|
+
if (authResult) {
|
|
701
|
+
customHeaders[authResult.headerName] = authResult.headerValue;
|
|
702
|
+
authMethod = 'auth-flow';
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
else if (headerArgs.some(h => /^authorization:/i.test(h))) {
|
|
706
|
+
authMethod = 'static-token';
|
|
356
707
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
console.log(chalk_1.default.gray(`
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
708
|
+
else if (mergedOptions.cookie) {
|
|
709
|
+
authMethod = 'cookie';
|
|
710
|
+
}
|
|
711
|
+
if (!ciMode) {
|
|
712
|
+
console.log(chalk_1.default.bold.cyan(`\n📸 APISnap v${version}`));
|
|
713
|
+
console.log(chalk_1.default.gray(` Target: ${baseUrl}`));
|
|
714
|
+
console.log(chalk_1.default.gray(` Slow: >${slowThreshold}ms`));
|
|
715
|
+
console.log(chalk_1.default.gray(` Timeout: ${timeout}ms`));
|
|
716
|
+
if (retryCount > 0)
|
|
717
|
+
console.log(chalk_1.default.gray(` Retries: ${retryCount} (exponential backoff)`));
|
|
718
|
+
if (concurrency > 1)
|
|
719
|
+
console.log(chalk_1.default.gray(` Concurrency: ${concurrency}`));
|
|
720
|
+
if (defaultBody)
|
|
721
|
+
console.log(chalk_1.default.gray(` Body: ${JSON.stringify(defaultBody)}`));
|
|
722
|
+
if (authMethod)
|
|
723
|
+
console.log(chalk_1.default.gray(` Auth: ${authMethod}`));
|
|
724
|
+
if (useSession)
|
|
725
|
+
console.log(chalk_1.default.gray(` Session: cookie jar enabled`));
|
|
726
|
+
if (onlyMethods)
|
|
727
|
+
console.log(chalk_1.default.gray(` Filter: ${onlyMethods.join(', ')}`));
|
|
369
728
|
const safeHeaders = { ...customHeaders };
|
|
370
|
-
// Mask auth tokens in output
|
|
371
729
|
Object.keys(safeHeaders).forEach(k => {
|
|
372
|
-
if (/auth|token|key|secret|cookie/i.test(k))
|
|
730
|
+
if (/auth|token|key|secret|cookie/i.test(k))
|
|
373
731
|
safeHeaders[k] = safeHeaders[k].slice(0, 8) + '••••••';
|
|
374
|
-
}
|
|
375
732
|
});
|
|
376
733
|
delete safeHeaders['User-Agent'];
|
|
377
|
-
|
|
734
|
+
if (Object.keys(safeHeaders).length > 0)
|
|
735
|
+
console.log(chalk_1.default.gray(` Headers: ${JSON.stringify(safeHeaders)}`));
|
|
736
|
+
console.log();
|
|
378
737
|
}
|
|
379
|
-
|
|
380
|
-
console.log(chalk_1.default.gray(` Filter: ${onlyMethods.join(', ')}`));
|
|
381
|
-
console.log();
|
|
382
|
-
const spinner = (0, ora_1.default)('Connecting to discovery endpoint...').start();
|
|
738
|
+
const spinner = ciMode ? null : (0, ora_1.default)('Connecting to discovery endpoint...').start();
|
|
383
739
|
const results = [];
|
|
384
740
|
try {
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
741
|
+
let endpoints = [];
|
|
742
|
+
const openApiPath = mergedOptions.openapi ?? fileConfig.openapi;
|
|
743
|
+
if (openApiPath) {
|
|
744
|
+
const configuredPath = openApiPath === true ? './openapi.json' : String(openApiPath);
|
|
745
|
+
const resolvedOpenApi = path_1.default.isAbsolute(configuredPath)
|
|
746
|
+
? configuredPath
|
|
747
|
+
: path_1.default.resolve(process.cwd(), configuredPath);
|
|
748
|
+
const openApiRaw = fs_1.default.readFileSync(resolvedOpenApi, 'utf-8');
|
|
749
|
+
const openApiSpec = JSON.parse(openApiRaw);
|
|
750
|
+
endpoints = parseOpenApiEndpoints(openApiSpec);
|
|
751
|
+
if (spinner)
|
|
752
|
+
spinner.succeed(chalk_1.default.green(`Loaded ${endpoints.length} endpoint${endpoints.length !== 1 ? 's' : ''} from OpenAPI spec.`));
|
|
753
|
+
}
|
|
754
|
+
else if (watchMode && cachedEndpoints) {
|
|
755
|
+
endpoints = cachedEndpoints;
|
|
756
|
+
if (spinner)
|
|
757
|
+
spinner.succeed(chalk_1.default.green(`Using cached discovery (${endpoints.length} endpoint${endpoints.length !== 1 ? 's' : ''}).`));
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
const discovery = await axios_1.default.get(discoveryUrl, { timeout: 5000 });
|
|
761
|
+
endpoints = discovery.data.endpoints;
|
|
762
|
+
if (watchMode)
|
|
763
|
+
cachedEndpoints = endpoints;
|
|
764
|
+
}
|
|
765
|
+
if (fileConfig.skip?.length) {
|
|
766
|
+
endpoints = endpoints.filter((e) => !fileConfig.skip.some((s) => e.path.startsWith(s)));
|
|
767
|
+
}
|
|
389
768
|
if (onlyMethods) {
|
|
390
769
|
endpoints = endpoints.filter((e) => e.methods.some((m) => onlyMethods.includes(m)));
|
|
391
770
|
}
|
|
392
|
-
|
|
771
|
+
if (mergedOptions.filter) {
|
|
772
|
+
endpoints = endpoints.filter((e) => pathMatchesFilter(e.path, mergedOptions.filter));
|
|
773
|
+
}
|
|
774
|
+
if (spinner?.isSpinning)
|
|
775
|
+
spinner.succeed(chalk_1.default.green(`Connected! Found ${endpoints.length} endpoint${endpoints.length !== 1 ? 's' : ''} to test.\n`));
|
|
776
|
+
if (mergedOptions['dry-run']) {
|
|
777
|
+
if (!ciMode) {
|
|
778
|
+
console.log(chalk_1.default.bold('\n🧪 Dry Run Endpoints:\n'));
|
|
779
|
+
endpoints.forEach((e) => {
|
|
780
|
+
const method = (e.methods?.[0] || 'GET').padEnd(7);
|
|
781
|
+
const resolved = replacePath(e.path, paramOverrides);
|
|
782
|
+
console.log(` ${method} ${resolved}`);
|
|
783
|
+
});
|
|
784
|
+
console.log();
|
|
785
|
+
}
|
|
786
|
+
process.exit(0);
|
|
787
|
+
}
|
|
393
788
|
let passed = 0, failed = 0, slow = 0;
|
|
394
789
|
const allDurations = [];
|
|
395
|
-
// ── Test Each Endpoint ───────────────────────────────────────────────
|
|
396
|
-
// ── Build task list ───────────────────────────────────────────────────────
|
|
397
790
|
const tasks = endpoints.map((endpoint) => async () => {
|
|
398
791
|
const method = endpoint.methods[0];
|
|
399
792
|
const rawPath = endpoint.path;
|
|
400
793
|
const resolvedPath = replacePath(rawPath, paramOverrides);
|
|
401
794
|
const fullUrl = `${baseUrl}${resolvedPath}`;
|
|
402
|
-
// Per-route body: check fileConfig.routes first, fall back to defaultBody
|
|
403
795
|
const routeConfig = (fileConfig.routes || []).find((r) => r.path === rawPath || r.path === resolvedPath);
|
|
404
796
|
const requestBody = routeConfig?.body ?? defaultBody;
|
|
797
|
+
const routeTimeout = routeConfig?.timeout ?? timeout;
|
|
798
|
+
const requestHeaders = { ...customHeaders };
|
|
799
|
+
if (routeConfig?.headers) {
|
|
800
|
+
Object.assign(requestHeaders, parseHeaders(routeConfig.headers));
|
|
801
|
+
}
|
|
802
|
+
if (routeConfig?.auth === 'none') {
|
|
803
|
+
delete requestHeaders['Authorization'];
|
|
804
|
+
delete requestHeaders['Cookie'];
|
|
805
|
+
}
|
|
806
|
+
if (useSession && cookieJar.has() && routeConfig?.auth !== 'none') {
|
|
807
|
+
const existing = requestHeaders['Cookie'] || '';
|
|
808
|
+
requestHeaders['Cookie'] = [existing, cookieJar.toString()].filter(Boolean).join('; ');
|
|
809
|
+
}
|
|
405
810
|
const testResult = {
|
|
406
811
|
method, path: rawPath, fullUrl,
|
|
407
812
|
status: 0, statusText: '', duration: 0,
|
|
408
813
|
success: false, slow: false, retries: 0,
|
|
814
|
+
authMethod: routeConfig?.auth === 'none' ? 'none' : authMethod,
|
|
409
815
|
};
|
|
410
|
-
const testSpinner = (0, ora_1.default)({
|
|
816
|
+
const testSpinner = (ciMode || concurrency > 1) ? null : (0, ora_1.default)({
|
|
411
817
|
text: `${chalk_1.default.bold(method.padEnd(7))} ${chalk_1.default.dim(rawPath)}`,
|
|
412
|
-
prefixText: ' '
|
|
818
|
+
prefixText: ' ',
|
|
413
819
|
}).start();
|
|
414
820
|
let lastError = null;
|
|
415
821
|
let attempt = 0;
|
|
@@ -419,13 +825,22 @@ program
|
|
|
419
825
|
const res = await (0, axios_1.default)({
|
|
420
826
|
method,
|
|
421
827
|
url: fullUrl,
|
|
422
|
-
headers:
|
|
423
|
-
timeout,
|
|
828
|
+
headers: requestHeaders,
|
|
829
|
+
timeout: routeTimeout,
|
|
424
830
|
validateStatus: () => true,
|
|
425
|
-
// Only send body for methods that accept one
|
|
426
831
|
data: ['POST', 'PUT', 'PATCH'].includes(method) ? requestBody : undefined,
|
|
427
832
|
});
|
|
428
833
|
const duration = Date.now() - start;
|
|
834
|
+
if (res.status === 429) {
|
|
835
|
+
const retryAfterMs = parseRetryAfterMs(res.headers['retry-after']);
|
|
836
|
+
if (!ciMode) {
|
|
837
|
+
process.stdout.write(chalk_1.default.gray(` rate-limited — waiting ${retryAfterMs}ms...\n`));
|
|
838
|
+
}
|
|
839
|
+
await sleep(retryAfterMs);
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
if (useSession)
|
|
843
|
+
cookieJar.ingest(res.headers['set-cookie']);
|
|
429
844
|
testResult.duration = duration;
|
|
430
845
|
testResult.status = res.status;
|
|
431
846
|
testResult.statusText = res.statusText;
|
|
@@ -437,34 +852,41 @@ program
|
|
|
437
852
|
const durationStr = testResult.slow
|
|
438
853
|
? chalk_1.default.yellow.bold(`${duration}ms ← slow!`)
|
|
439
854
|
: chalk_1.default.gray(`${duration}ms`);
|
|
440
|
-
const msg = `${chalk_1.default.bold(method.padEnd(7))} ${chalk_1.default.white(rawPath.padEnd(35))} `
|
|
441
|
-
|
|
442
|
-
|
|
855
|
+
const msg = `${chalk_1.default.bold(method.padEnd(7))} ${chalk_1.default.white(rawPath.padEnd(35))} ${chalk_1.default.green(`[${res.status}]`)} ${durationStr}`;
|
|
856
|
+
if (testSpinner)
|
|
857
|
+
testResult.slow ? testSpinner.warn(msg) : testSpinner.succeed(msg);
|
|
443
858
|
passed++;
|
|
444
859
|
if (testResult.slow)
|
|
445
860
|
slow++;
|
|
446
861
|
}
|
|
447
862
|
else {
|
|
448
|
-
|
|
449
|
-
`${chalk_1.default.red(`[${res.status} ${res.statusText}]`)} ${chalk_1.default.gray(`${duration}ms`)}`);
|
|
863
|
+
if (testSpinner)
|
|
864
|
+
testSpinner.fail(`${chalk_1.default.bold(method.padEnd(7))} ${chalk_1.default.white(rawPath.padEnd(35))} ${chalk_1.default.red(`[${res.status} ${res.statusText}]`)} ${chalk_1.default.gray(`${duration}ms`)}`);
|
|
450
865
|
const paramNames = [...rawPath.matchAll(/:([a-zA-Z0-9_]+)/g)].map(match => match[1]);
|
|
451
|
-
const exampleParams = paramNames.reduce((acc,
|
|
452
|
-
if (
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
866
|
+
const exampleParams = paramNames.reduce((acc, p) => ({ ...acc, [p]: '1' }), {});
|
|
867
|
+
if (!ciMode) {
|
|
868
|
+
if (res.status === 401) {
|
|
869
|
+
if (useAuthFlow) {
|
|
870
|
+
console.log(chalk_1.default.yellow(' hint: auth flow ran but this route still returned 401 — check token permissions'));
|
|
871
|
+
}
|
|
872
|
+
else {
|
|
873
|
+
console.log(chalk_1.default.yellow(' hint: No credentials — use --auth-flow or add "headers" / "authFlow" to .apisnaprc'));
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
else if (res.status === 403) {
|
|
877
|
+
console.log(chalk_1.default.yellow(' hint: Token lacks permission for this route — check RBAC/scopes'));
|
|
878
|
+
}
|
|
879
|
+
else if (res.status === 404 && paramNames.length > 0) {
|
|
880
|
+
console.log(chalk_1.default.yellow(' hint: path needs params — add to .apisnaprc:'));
|
|
881
|
+
console.log(chalk_1.default.gray(` "params": ${JSON.stringify(exampleParams)}`));
|
|
882
|
+
}
|
|
883
|
+
else if (res.status === 404) {
|
|
884
|
+
console.log(chalk_1.default.yellow(' hint: route not found — is the server fully started?'));
|
|
885
|
+
}
|
|
886
|
+
else if (res.status === 400 || res.status === 422) {
|
|
887
|
+
console.log(chalk_1.default.yellow(' hint: add a request body to .apisnaprc under "routes":'));
|
|
888
|
+
console.log(chalk_1.default.gray(` { "path": "${rawPath}", "body": {"field": "value"} }`));
|
|
889
|
+
}
|
|
468
890
|
}
|
|
469
891
|
failed++;
|
|
470
892
|
}
|
|
@@ -474,42 +896,37 @@ program
|
|
|
474
896
|
catch (err) {
|
|
475
897
|
lastError = err;
|
|
476
898
|
attempt++;
|
|
477
|
-
if (attempt <= retryCount)
|
|
478
|
-
|
|
899
|
+
if (attempt <= retryCount) {
|
|
900
|
+
const delay = backoffDelay(attempt - 1);
|
|
901
|
+
if (!ciMode)
|
|
902
|
+
process.stdout.write(chalk_1.default.gray(` ↺ retry ${attempt}/${retryCount} in ${Math.round(delay)}ms...\n`));
|
|
903
|
+
await new Promise(r => setTimeout(r, delay));
|
|
904
|
+
}
|
|
479
905
|
}
|
|
480
906
|
}
|
|
481
907
|
if (lastError) {
|
|
482
908
|
testResult.success = false;
|
|
483
909
|
testResult.retries = attempt - 1;
|
|
484
910
|
testResult.error = lastError.code === 'ECONNABORTED' ? 'Timeout' : lastError.message;
|
|
485
|
-
|
|
486
|
-
chalk_1.default.red(`[${testResult.error}]`));
|
|
911
|
+
if (testSpinner)
|
|
912
|
+
testSpinner.fail(`${chalk_1.default.bold(method.padEnd(7))} ${chalk_1.default.white(rawPath.padEnd(35))} ${chalk_1.default.red(`[${testResult.error}]`)}`);
|
|
487
913
|
failed++;
|
|
488
914
|
}
|
|
489
915
|
return testResult;
|
|
490
916
|
});
|
|
491
|
-
// ── Run with concurrency limit ────────────────────────────────────────────
|
|
492
917
|
const allResults = await runWithConcurrency(tasks, concurrency);
|
|
493
918
|
for (let i = 0; i < allResults.length; i++) {
|
|
494
919
|
const result = allResults[i];
|
|
495
920
|
if (result instanceof Error) {
|
|
496
|
-
const
|
|
497
|
-
const method =
|
|
498
|
-
const rawPath =
|
|
499
|
-
const resolvedPath = replacePath(rawPath, paramOverrides);
|
|
500
|
-
const fullUrl = `${baseUrl}${resolvedPath}`;
|
|
501
|
-
console.error(chalk_1.default.red(` Internal error on task ${i}: ${result.message}`));
|
|
921
|
+
const ep = endpoints[i];
|
|
922
|
+
const method = ep?.methods?.[0] || 'UNKNOWN';
|
|
923
|
+
const rawPath = ep?.path || '(unknown route)';
|
|
502
924
|
results.push({
|
|
503
|
-
method,
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
duration: 0,
|
|
509
|
-
success: false,
|
|
510
|
-
slow: false,
|
|
511
|
-
error: `Internal task failure: ${result.message}`,
|
|
512
|
-
retries: 0,
|
|
925
|
+
method, path: rawPath,
|
|
926
|
+
fullUrl: `${baseUrl}${replacePath(rawPath, paramOverrides)}`,
|
|
927
|
+
status: 0, statusText: 'Internal Error', duration: 0,
|
|
928
|
+
success: false, slow: false,
|
|
929
|
+
error: `Internal task failure: ${result.message}`, retries: 0,
|
|
513
930
|
});
|
|
514
931
|
failed++;
|
|
515
932
|
}
|
|
@@ -517,67 +934,113 @@ program
|
|
|
517
934
|
results.push(result);
|
|
518
935
|
}
|
|
519
936
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
}
|
|
534
|
-
else if (slow > 0) {
|
|
535
|
-
console.log(chalk_1.default.yellow.bold('\n🐢 All alive, but some routes are slow!'));
|
|
937
|
+
if (!ciMode && concurrency > 1) {
|
|
938
|
+
for (const r of results) {
|
|
939
|
+
const statusLabel = r.success
|
|
940
|
+
? chalk_1.default.green(`[${r.status}]`)
|
|
941
|
+
: chalk_1.default.red(`[${r.status || 'ERR'} ${r.statusText || ''}]`);
|
|
942
|
+
const durationLabel = r.slow
|
|
943
|
+
? chalk_1.default.yellow.bold(`${r.duration}ms ← slow!`)
|
|
944
|
+
: chalk_1.default.gray(`${r.duration}ms`);
|
|
945
|
+
console.log(` ${chalk_1.default.bold(r.method.padEnd(7))} ${chalk_1.default.white(r.path.padEnd(35))} ${statusLabel} ${durationLabel}`.trimEnd());
|
|
946
|
+
if (!r.success && r.error) {
|
|
947
|
+
console.log(chalk_1.default.gray(` ${r.error}`));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
536
950
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
console.log(chalk_1.default.bgYellow.black.bold('\n🔐 Auth Help'));
|
|
544
|
-
console.log(chalk_1.default.yellow(' You have ' + authFailures.length + ' auth failure(s) and no credentials were provided.'));
|
|
545
|
-
console.log(chalk_1.default.yellow(' Solutions:'));
|
|
546
|
-
console.log(chalk_1.default.gray(' JWT: apisnap -H "Authorization: Bearer YOUR_JWT_TOKEN"'));
|
|
547
|
-
console.log(chalk_1.default.gray(' API Key: apisnap -H "x-api-key: YOUR_KEY"'));
|
|
548
|
-
console.log(chalk_1.default.gray(' Cookie: apisnap --cookie "sessionId=abc123"'));
|
|
549
|
-
console.log(chalk_1.default.gray(' Multi: apisnap -H "Authorization: Bearer TOKEN" -H "x-tenant: acme"'));
|
|
550
|
-
console.log(chalk_1.default.gray(' Config: create .apisnaprc.json (see README)\n'));
|
|
551
|
-
}
|
|
552
|
-
// ── Exports ──────────────────────────────────────────────────────────
|
|
951
|
+
const avgDuration = allDurations.length > 0 ? Math.round(allDurations.reduce((a, b) => a + b, 0) / allDurations.length) : 0;
|
|
952
|
+
const totalDuration = allDurations.reduce((a, b) => a + b, 0);
|
|
953
|
+
const sortedDurations = [...allDurations].sort((a, b) => a - b);
|
|
954
|
+
const p50Duration = percentile(sortedDurations, 50);
|
|
955
|
+
const p95Duration = percentile(sortedDurations, 95);
|
|
956
|
+
const p99Duration = percentile(sortedDurations, 99);
|
|
553
957
|
const reportData = {
|
|
554
958
|
tool: 'APISnap', version,
|
|
555
959
|
generatedAt: new Date().toISOString(),
|
|
556
960
|
config: { port, baseUrl, slowThreshold, timeout, headers: Object.keys(customHeaders).filter(k => k !== 'User-Agent') },
|
|
557
|
-
summary: { total: endpoints.length, passed, failed, slow, avgDuration, totalDuration },
|
|
961
|
+
summary: { total: endpoints.length, passed, failed, slow, avgDuration, totalDuration, p50Duration, p95Duration, p99Duration },
|
|
558
962
|
results,
|
|
559
963
|
};
|
|
964
|
+
const diffFile = mergedOptions.diff;
|
|
965
|
+
let diff = null;
|
|
966
|
+
if (diffFile) {
|
|
967
|
+
const diffPath = diffFile.endsWith('.json') ? diffFile : `${diffFile}.json`;
|
|
968
|
+
diff = diffAgainstBaseline(reportData, diffPath);
|
|
969
|
+
if (diff)
|
|
970
|
+
printDiffReport(diff);
|
|
971
|
+
}
|
|
972
|
+
if (!ciMode) {
|
|
973
|
+
console.log(chalk_1.default.bold('\n📊 Summary:'));
|
|
974
|
+
console.log(` ${chalk_1.default.green('✅ Passed: ')} ${chalk_1.default.bold(passed)}`);
|
|
975
|
+
console.log(` ${chalk_1.default.red('❌ Failed: ')} ${chalk_1.default.bold(failed)}`);
|
|
976
|
+
console.log(` ${chalk_1.default.yellow('⚠️ Slow: ')} ${chalk_1.default.bold(slow)} (>${slowThreshold}ms)`);
|
|
977
|
+
console.log(` ${chalk_1.default.cyan('⏱ Avg: ')} ${chalk_1.default.bold(avgDuration + 'ms')}`);
|
|
978
|
+
console.log(` ${chalk_1.default.cyan('📈 p50: ')} ${chalk_1.default.bold(p50Duration + 'ms')}`);
|
|
979
|
+
console.log(` ${chalk_1.default.cyan('📈 p95: ')} ${chalk_1.default.bold(p95Duration + 'ms')}`);
|
|
980
|
+
console.log(` ${chalk_1.default.cyan('📈 p99: ')} ${chalk_1.default.bold(p99Duration + 'ms')}`);
|
|
981
|
+
console.log(` ${chalk_1.default.cyan('🕐 Total: ')} ${chalk_1.default.bold(totalDuration + 'ms')}`);
|
|
982
|
+
if (failed > 0)
|
|
983
|
+
console.log(chalk_1.default.red.bold('\n⚠️ Some endpoints are unhealthy!'));
|
|
984
|
+
else if (slow > 0)
|
|
985
|
+
console.log(chalk_1.default.yellow.bold('\n🐢 All alive, but some routes are slow!'));
|
|
986
|
+
else
|
|
987
|
+
console.log(chalk_1.default.green.bold('\n✨ All systems nominal!'));
|
|
988
|
+
const authFailures = results.filter(r => r.status === 401 || r.status === 403);
|
|
989
|
+
if (authFailures.length > 0 && !headerArgs.length && !mergedOptions.cookie && !useAuthFlow) {
|
|
990
|
+
console.log(chalk_1.default.bgYellow.black.bold('\n🔐 Auth Help'));
|
|
991
|
+
console.log(chalk_1.default.yellow(` You have ${authFailures.length} auth failure(s) and no credentials were provided.`));
|
|
992
|
+
console.log(chalk_1.default.yellow(' Solutions:'));
|
|
993
|
+
console.log(chalk_1.default.gray(' Auto-login: add "authFlow" to .apisnaprc + run: apisnap --auth-flow'));
|
|
994
|
+
console.log(chalk_1.default.gray(' JWT: apisnap -H "Authorization: Bearer YOUR_JWT_TOKEN"'));
|
|
995
|
+
console.log(chalk_1.default.gray(' API Key: apisnap -H "x-api-key: YOUR_KEY"'));
|
|
996
|
+
console.log(chalk_1.default.gray(' Cookie: apisnap --cookie "sessionId=abc123"'));
|
|
997
|
+
console.log(chalk_1.default.gray(' Session: apisnap --session (auto cookie jar)\n'));
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const saveBaselineFlag = mergedOptions.saveBaseline || mergedOptions['save-baseline'];
|
|
1001
|
+
if (saveBaselineFlag) {
|
|
1002
|
+
const bp = saveBaselineFlag.endsWith('.json') ? saveBaselineFlag : `${saveBaselineFlag}.json`;
|
|
1003
|
+
fs_1.default.writeFileSync(bp, JSON.stringify(reportData, null, 2));
|
|
1004
|
+
if (!ciMode)
|
|
1005
|
+
console.log(chalk_1.default.cyan(`\n💾 Baseline saved → ${chalk_1.default.white(bp)}`));
|
|
1006
|
+
}
|
|
560
1007
|
if (mergedOptions.export) {
|
|
561
|
-
const
|
|
562
|
-
fs_1.default.writeFileSync(
|
|
563
|
-
|
|
1008
|
+
const fp = mergedOptions.export.endsWith('.json') ? mergedOptions.export : `${mergedOptions.export}.json`;
|
|
1009
|
+
fs_1.default.writeFileSync(fp, JSON.stringify(reportData, null, 2));
|
|
1010
|
+
if (!ciMode)
|
|
1011
|
+
console.log(chalk_1.default.cyan(`\n💾 JSON report → ${chalk_1.default.white(fp)}`));
|
|
564
1012
|
}
|
|
565
1013
|
if (mergedOptions.html) {
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
1014
|
+
const fp = mergedOptions.html.endsWith('.html') ? mergedOptions.html : `${mergedOptions.html}.html`;
|
|
1015
|
+
await writeHTMLReport(fp, reportData, diff);
|
|
1016
|
+
if (!ciMode)
|
|
1017
|
+
console.log(chalk_1.default.cyan(`🌐 HTML report → ${chalk_1.default.white(fp)}`));
|
|
1018
|
+
}
|
|
1019
|
+
if (ciMode) {
|
|
1020
|
+
const ciOutput = {
|
|
1021
|
+
...reportData,
|
|
1022
|
+
diff: diff ?? undefined,
|
|
1023
|
+
exitCode: (failed > 0 || (mergedOptions['fail-on-slow'] && slow > 0) || (diff?.regressions?.length ?? 0) > 0) ? 1 : 0,
|
|
1024
|
+
};
|
|
1025
|
+
process.stdout.write(JSON.stringify(ciOutput, null, 2) + '\n');
|
|
569
1026
|
}
|
|
570
1027
|
console.log();
|
|
571
|
-
|
|
572
|
-
const shouldFail = failed > 0
|
|
1028
|
+
const hasRegressions = diff?.regressions?.length ?? 0;
|
|
1029
|
+
const shouldFail = failed > 0
|
|
1030
|
+
|| (mergedOptions['fail-on-slow'] && slow > 0)
|
|
1031
|
+
|| (hasRegressions > 0);
|
|
573
1032
|
process.exit(shouldFail ? 1 : 0);
|
|
574
1033
|
}
|
|
575
1034
|
catch (error) {
|
|
576
|
-
spinner
|
|
1035
|
+
if (spinner)
|
|
1036
|
+
spinner.fail(chalk_1.default.red('Could not connect to your server.'));
|
|
577
1037
|
const isRefused = error?.code === 'ECONNREFUSED';
|
|
578
1038
|
const isTimeout = error?.code === 'ECONNABORTED' || error?.code === 'ETIMEDOUT';
|
|
579
1039
|
const isNoInit = error?.response?.status === 404;
|
|
580
|
-
if (
|
|
1040
|
+
if (ciMode) {
|
|
1041
|
+
process.stdout.write(JSON.stringify({ error: error.message, code: error.code }) + '\n');
|
|
1042
|
+
}
|
|
1043
|
+
else if (isRefused) {
|
|
581
1044
|
console.log(chalk_1.default.yellow(`\n Your server is not running on port ${port}.`));
|
|
582
1045
|
console.log(chalk_1.default.gray(' → Start it first, then run apisnap again.\n'));
|
|
583
1046
|
}
|
|
@@ -588,7 +1051,7 @@ program
|
|
|
588
1051
|
else if (isNoInit) {
|
|
589
1052
|
console.log(chalk_1.default.yellow('\n Server is running but APISnap middleware not found.'));
|
|
590
1053
|
console.log(chalk_1.default.gray(' → Add this to your server AFTER your routes:\n'));
|
|
591
|
-
console.log(chalk_1.default.cyan(
|
|
1054
|
+
console.log(chalk_1.default.cyan(" const apisnap = require('@umeshindu222/apisnap');"));
|
|
592
1055
|
console.log(chalk_1.default.cyan(' apisnap.init(app);\n'));
|
|
593
1056
|
}
|
|
594
1057
|
else {
|
|
@@ -600,7 +1063,6 @@ program
|
|
|
600
1063
|
process.exit(1);
|
|
601
1064
|
}
|
|
602
1065
|
});
|
|
603
|
-
// Allows -H to be used multiple times
|
|
604
1066
|
function collect(val, prev) {
|
|
605
1067
|
return prev.concat([val]);
|
|
606
1068
|
}
|