@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.
@@ -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 = __importDefault(require("fs"));
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 { ...config, ...config.envs[env] };
131
+ return interpolateEnv(envMerged);
62
132
  }
63
- return config;
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
- const key = h.slice(0, colonIdx).trim();
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'; // fallback
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 generateHTMLReport(data) {
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 rows = data.results.map(r => `
186
- <tr style="background:${rowColor(r)}">
187
- <td><span class="badge badge-${r.method.toLowerCase()}">${r.method}</span></td>
188
- <td><code>${r.path}</code></td>
189
- <td>${r.success
190
- ? `<span class="ok">✔ ${r.status}</span>`
191
- : `<span class="fail">✖ ${r.status || 'ERR'}</span>`}</td>
192
- <td>${r.slow ? `<span class="slow">⚠️ ${r.duration}ms</span>` : `${r.duration}ms`}</td>
193
- <td>${r.retries > 0 ? `${r.retries} retry` : ''}</td>
194
- <td>${r.error ? `<span class="errtext">${r.error}</span>` : ''}</td>
195
- </tr>
196
- `).join('');
197
- return `<!DOCTYPE html>
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 (&gt;${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
- <tbody>${rows}</tbody>
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(' Auth token? ') + chalk_1.default.gray('(leave blank to skip): '));
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
- if (skipRaw.trim()) {
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 — can be used multiple times (e.g. -H "Authorization: Bearer TOKEN" -H "x-api-key: SECRET")', collect, [])
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 (e.g. report)')
304
- .option('--html <filename>', 'Export HTML report (e.g. 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 (e.g. staging, prod)')
307
- .option('--base-url <url>', 'Override base URL (e.g. https://staging.myapp.com)')
308
- .option('--params <json>', 'JSON map of param overrides (e.g. \'{"id":"42"}\')')
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 (e.g. \'{"name":"test"}\')')
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 paramOverrides = mergedOptions.params
333
- ? (typeof mergedOptions.params === 'string'
334
- ? JSON.parse(mergedOptions.params) // from CLI flag — parse it
335
- : mergedOptions.params) // from config file — already an object
336
- : (fileConfig.params || {});
337
- const concurrency = parseIntOption(mergedOptions.concurrency, 'concurrency', 1, { min: 1, max: 50 });
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
- // Build headers
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
- // ── Banner ──────────────────────────────────────────────────────────────
358
- console.log(chalk_1.default.bold.cyan(`\n📸 APISnap v${version}`));
359
- console.log(chalk_1.default.gray(` Target: ${baseUrl}`));
360
- console.log(chalk_1.default.gray(` Slow: >${slowThreshold}ms`));
361
- console.log(chalk_1.default.gray(` Timeout: ${timeout}ms`));
362
- if (retryCount > 0)
363
- console.log(chalk_1.default.gray(` Retries: ${retryCount}`));
364
- if (concurrency > 1)
365
- console.log(chalk_1.default.gray(` Concurrency: ${concurrency}`));
366
- if (defaultBody)
367
- console.log(chalk_1.default.gray(` Body: ${JSON.stringify(defaultBody)}`));
368
- if (Object.keys(customHeaders).filter(k => k !== 'User-Agent').length > 0) {
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
- console.log(chalk_1.default.gray(` Headers: ${JSON.stringify(safeHeaders)}`));
734
+ if (Object.keys(safeHeaders).length > 0)
735
+ console.log(chalk_1.default.gray(` Headers: ${JSON.stringify(safeHeaders)}`));
736
+ console.log();
378
737
  }
379
- if (onlyMethods)
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
- // ── Discovery ───────────────────────────────────────────────────────
386
- const discovery = await axios_1.default.get(discoveryUrl, { timeout: 5000 });
387
- let { endpoints } = discovery.data;
388
- // Filter by method if --only flag provided
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
- spinner.succeed(chalk_1.default.green(`Connected! Found ${endpoints.length} endpoint${endpoints.length !== 1 ? 's' : ''} to test.\n`));
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: customHeaders,
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
- `${chalk_1.default.green(`[${res.status}]`)} ${durationStr}`;
442
- testResult.slow ? testSpinner.warn(msg) : testSpinner.succeed(msg);
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
- testSpinner.fail(`${chalk_1.default.bold(method.padEnd(7))} ${chalk_1.default.white(rawPath.padEnd(35))} ` +
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, paramName) => ({ ...acc, [paramName]: '1' }), {});
452
- if (res.status === 401 || res.status === 403) {
453
- const hint = res.status === 401
454
- ? 'No credentials sent — add to .apisnaprc: "headers": ["Authorization: Bearer TOKEN"]'
455
- : 'Token lacks permission for this route';
456
- console.log(chalk_1.default.yellow(` hint: ${hint}`));
457
- }
458
- else if (res.status === 404 && paramNames.length > 0) {
459
- console.log(chalk_1.default.yellow(' hint: path needs params — add to .apisnaprc:'));
460
- console.log(chalk_1.default.gray(` "params": ${JSON.stringify(exampleParams)}`));
461
- }
462
- else if (res.status === 404) {
463
- console.log(chalk_1.default.yellow(' hint: route not found — is the server fully started?'));
464
- }
465
- else if (res.status === 400 || res.status === 422) {
466
- console.log(chalk_1.default.yellow(' hint: add a request body to .apisnaprc under "routes":'));
467
- console.log(chalk_1.default.gray(` { "path": "${rawPath}", "body": {"field": "value"} }`));
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
- await new Promise(r => setTimeout(r, 500 * attempt));
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
- testSpinner.fail(`${chalk_1.default.bold(method.padEnd(7))} ${chalk_1.default.white(rawPath.padEnd(35))} ` +
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 endpoint = endpoints[i];
497
- const method = endpoint?.methods?.[0] || 'UNKNOWN';
498
- const rawPath = endpoint?.path || '(unknown route)';
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
- path: rawPath,
505
- fullUrl,
506
- status: 0,
507
- statusText: 'Internal Error',
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
- // ── Summary ──────────────────────────────────────────────────────────
521
- const avgDuration = allDurations.length > 0
522
- ? Math.round(allDurations.reduce((a, b) => a + b, 0) / allDurations.length)
523
- : 0;
524
- const totalDuration = allDurations.reduce((a, b) => a + b, 0);
525
- console.log(chalk_1.default.bold('\n📊 Summary:'));
526
- console.log(` ${chalk_1.default.green('✅ Passed: ')} ${chalk_1.default.bold(passed)}`);
527
- console.log(` ${chalk_1.default.red('❌ Failed: ')} ${chalk_1.default.bold(failed)}`);
528
- console.log(` ${chalk_1.default.yellow('⚠️ Slow: ')} ${chalk_1.default.bold(slow)} (>${slowThreshold}ms)`);
529
- console.log(` ${chalk_1.default.cyan('⏱ Avg: ')} ${chalk_1.default.bold(avgDuration + 'ms')}`);
530
- console.log(` ${chalk_1.default.cyan('🕐 Total: ')} ${chalk_1.default.bold(totalDuration + 'ms')}`);
531
- if (failed > 0) {
532
- console.log(chalk_1.default.red.bold('\n⚠️ Some endpoints are unhealthy!'));
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
- else {
538
- console.log(chalk_1.default.green.bold('\n✨ All systems nominal!'));
539
- }
540
- // ── Auth Troubleshooting Summary ─────────────────────────────────────
541
- const authFailures = results.filter(r => r.status === 401 || r.status === 403);
542
- if (authFailures.length > 0 && !headerArgs.length && !mergedOptions.cookie) {
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 filePath = mergedOptions.export.endsWith('.json') ? mergedOptions.export : `${mergedOptions.export}.json`;
562
- fs_1.default.writeFileSync(filePath, JSON.stringify(reportData, null, 2));
563
- console.log(chalk_1.default.cyan(`\n💾 JSON report → ${chalk_1.default.white(filePath)}`));
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 filePath = mergedOptions.html.endsWith('.html') ? mergedOptions.html : `${mergedOptions.html}.html`;
567
- fs_1.default.writeFileSync(filePath, generateHTMLReport(reportData));
568
- console.log(chalk_1.default.cyan(`🌐 HTML report → ${chalk_1.default.white(filePath)}`));
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
- // Exit codes for CI/CD
572
- const shouldFail = failed > 0 || (mergedOptions['fail-on-slow'] && slow > 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.fail(chalk_1.default.red('Could not connect to your server.'));
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 (isRefused) {
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(' const apisnap = require(\'@umeshindu222/apisnap\');'));
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
  }