@testsmith/api-spector 0.1.9 → 0.2.0

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.
@@ -3,13 +3,14 @@
3
3
  const promises = require("fs/promises");
4
4
  const path = require("path");
5
5
  const undici = require("undici");
6
- const requestHandler = require("./chunks/request-handler-DAXUMRyd.js");
6
+ const requestHandler = require("./chunks/request-handler-AFBOd__c.js");
7
7
  require("crypto");
8
8
  require("dayjs");
9
9
  require("vm");
10
10
  require("tv4");
11
11
  require("jsonpath-plus");
12
12
  require("@xmldom/xmldom");
13
+ require("ajv");
13
14
  function buildJsonReport(results, summary, meta = {}) {
14
15
  return JSON.stringify({
15
16
  timestamp: meta.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
@@ -62,8 +63,13 @@ function buildHtmlReport(results, summary, meta = {}) {
62
63
  after: "AFTER",
63
64
  afterAll: "AFTER ALL"
64
65
  };
66
+ let lastScopeKey = null;
65
67
  const cards = results.map((r, idx) => {
66
- const statusCls = r.status === "passed" ? "badge-pass" : r.status === "failed" ? "badge-fail" : "badge-err";
68
+ const scopeKey = (r.scopePath ?? []).join(" / ");
69
+ const groupHeading = scopeKey && scopeKey !== lastScopeKey ? ` <div class="scope-heading">${esc(scopeKey)}</div>
70
+ ` : "";
71
+ lastScopeKey = scopeKey;
72
+ const statusCls = r.status === "passed" ? "badge-pass" : r.status === "failed" ? "badge-fail" : r.status === "skipped" ? "badge-skip" : "badge-err";
67
73
  const httpCls = r.httpStatus && r.httpStatus < 300 ? "http-ok" : r.httpStatus && r.httpStatus < 400 ? "http-redir" : "http-err";
68
74
  const dur = r.durationMs != null ? `${r.durationMs} ms` : "—";
69
75
  const label = r.iterationLabel ? ` <span class="muted">#${esc(r.iterationLabel)}</span>` : "";
@@ -101,7 +107,7 @@ function buildHtmlReport(results, summary, meta = {}) {
101
107
  ${resp.body ? `<div class="section-label">Body</div><pre class="code-block">${prettyJson(resp.body)}</pre>` : ""}
102
108
  </div>` : "";
103
109
  const hookCls = hookLabel ? r.hookType?.startsWith("before") ? "card-hook-before" : "card-hook-after" : "";
104
- return `
110
+ return `${groupHeading}
105
111
  <div class="card ${hookCls}" id="r${idx}">
106
112
  <div class="card-header" onclick="toggle(${idx})">
107
113
  <span class="chevron" id="ch${idx}">▶</span>
@@ -142,8 +148,11 @@ function buildHtmlReport(results, summary, meta = {}) {
142
148
  .stat-pass .stat-val { color: #3fb950; }
143
149
  .stat-fail .stat-val { color: #f85149; }
144
150
  .stat-err .stat-val { color: #d29922; }
151
+ .stat-skip .stat-val { color: #8b949e; }
145
152
  /* Cards */
146
153
  .card { border: 1px solid #21262d; border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
154
+ .scope-heading { margin: 18px 0 6px; padding: 4px 2px; font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #21262d; }
155
+ .scope-heading::before { content: "▸ "; color: #484f58; }
147
156
  .card-header { display: flex; align-items: baseline; gap: 8px; padding: 10px 14px; cursor: pointer; user-select: none; }
148
157
  .card-header:hover { background: #161b22; }
149
158
  .chevron { font-size: 10px; color: #8b949e; min-width: 10px; transition: transform .15s; }
@@ -155,6 +164,7 @@ function buildHtmlReport(results, summary, meta = {}) {
155
164
  .badge-pass { background: #0d3a1e; color: #3fb950; }
156
165
  .badge-fail { background: #3d1014; color: #f85149; }
157
166
  .badge-err { background: #3d2a00; color: #d29922; }
167
+ .badge-skip { background: #21262d; color: #8b949e; }
158
168
  .method { font-family: monospace; font-size: 11px; font-weight: 700; color: #79c0ff; white-space: nowrap; }
159
169
  .method-badge { display: inline-block; font-family: monospace; font-size: 11px; font-weight: 700; color: #79c0ff; min-width: 52px; }
160
170
  .http-badge { font-family: monospace; font-size: 11px; font-weight: 600; white-space: nowrap; }
@@ -208,6 +218,7 @@ function buildHtmlReport(results, summary, meta = {}) {
208
218
  <div class="stat stat-pass"><div class="stat-val">${summary.passed}</div><div class="stat-lbl">Passed</div></div>
209
219
  <div class="stat stat-fail"><div class="stat-val">${summary.failed}</div><div class="stat-lbl">Failed</div></div>
210
220
  <div class="stat stat-err"><div class="stat-val">${summary.errors}</div><div class="stat-lbl">Errors</div></div>
221
+ <div class="stat stat-skip"><div class="stat-val">${summary.skipped ?? 0}</div><div class="stat-lbl">No tests</div></div>
211
222
  <div class="stat"><div class="stat-val">${passRate}%</div><div class="stat-lbl">Pass rate</div></div>
212
223
  <div class="stat"><div class="stat-val">${summary.durationMs} ms</div><div class="stat-lbl">Duration</div></div>
213
224
  </div>
@@ -251,6 +262,8 @@ function buildJUnitReport(results, summary, meta = {}) {
251
262
  failures.push(` <error message="${esc(r.preScriptError)}" type="PreScriptError" />`);
252
263
  } else if (r.postScriptError) {
253
264
  failures.push(` <error message="${esc(r.postScriptError)}" type="PostScriptError" />`);
265
+ } else if (r.status === "skipped") {
266
+ failures.push(' <skipped message="No assertions defined for this request" />');
254
267
  } else if (r.testResults?.length) {
255
268
  for (const t of r.testResults) {
256
269
  if (!t.passed) {
@@ -265,29 +278,31 @@ ${failures.join("\n")}
265
278
  ` : "";
266
279
  return ` <testcase name="${name}" classname="${classname}" time="${timeSec}">${inner}</testcase>`;
267
280
  });
281
+ const skipped = summary.skipped ?? 0;
268
282
  const lines = [
269
283
  '<?xml version="1.0" encoding="UTF-8"?>',
270
- `<testsuites name="${suiteName}" tests="${summary.total}" failures="${summary.failed}" errors="${summary.errors}" time="${totalSec}">`,
271
- ` <testsuite name="${suiteName}" tests="${summary.total}" failures="${summary.failed}" errors="${summary.errors}" time="${totalSec}" timestamp="${ts}">`,
284
+ `<testsuites name="${suiteName}" tests="${summary.total}" failures="${summary.failed}" errors="${summary.errors}" skipped="${skipped}" time="${totalSec}">`,
285
+ ` <testsuite name="${suiteName}" tests="${summary.total}" failures="${summary.failed}" errors="${summary.errors}" skipped="${skipped}" time="${totalSec}" timestamp="${ts}">`,
272
286
  ...cases,
273
287
  " </testsuite>",
274
288
  "</testsuites>"
275
289
  ];
276
290
  return lines.join("\n") + "\n";
277
291
  }
278
- function collectTagged(folder, requests, collectionVars, filterTags) {
292
+ function collectTagged(folder, requests, collectionVars, filterTags, parentPath = [], isRoot = true) {
279
293
  const results = [];
294
+ const scopePath = isRoot ? parentPath : [...parentPath, folder.name];
280
295
  for (const reqId of folder.requestIds) {
281
296
  const req = requests[reqId];
282
- if (!req || req.hookType) continue;
297
+ if (!req || req.hookType || req.disabled) continue;
283
298
  const tags = req.meta?.tags ?? [];
284
299
  if (filterTags.length > 0 && !filterTags.some((t) => tags.includes(t))) continue;
285
- results.push({ request: req, collectionVars });
300
+ results.push({ request: req, collectionVars, scopePath });
286
301
  }
287
302
  for (const sub of folder.folders) {
288
303
  const folderTags = sub.tags ?? [];
289
304
  const effectiveTags = filterTags.length === 0 ? filterTags : folderTags.some((t) => filterTags.includes(t)) ? [] : filterTags;
290
- results.push(...collectTagged(sub, requests, collectionVars, effectiveTags));
305
+ results.push(...collectTagged(sub, requests, collectionVars, effectiveTags, scopePath, false));
291
306
  }
292
307
  return results;
293
308
  }
@@ -370,8 +385,10 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
370
385
  let updatedCollectionVars = { ...collectionVars };
371
386
  let updatedGlobals = { ...globals };
372
387
  let preScriptError;
388
+ const dynamicVars = await requestHandler.buildDynamicVars();
389
+ let vars = requestHandler.mergeVars(envVars, collectionVars, globals, localVars, dynamicVars);
373
390
  if (req.preRequestScript?.trim()) {
374
- const r = await requestHandler.runScript(req.preRequestScript, {
391
+ const r = await requestHandler.runScript(requestHandler.interpolate(req.preRequestScript, vars), {
375
392
  envVars: { ...envVars },
376
393
  collectionVars: { ...collectionVars },
377
394
  globals: { ...globals },
@@ -387,8 +404,7 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
387
404
  if (verbose && r.consoleOutput.length) r.consoleOutput.forEach((l) => console.log(color(` [pre] ${l}`, C.gray)));
388
405
  if (r.error) console.error(color(` [pre-script error] ${r.error}`, C.red));
389
406
  }
390
- const dynamicVars = await requestHandler.buildDynamicVars();
391
- const vars = requestHandler.mergeVars(updatedEnvVars, updatedCollectionVars, updatedGlobals, localVars, dynamicVars);
407
+ vars = requestHandler.mergeVars(updatedEnvVars, updatedCollectionVars, updatedGlobals, localVars, dynamicVars);
392
408
  const resolvedUrl = requestHandler.buildUrl(req.url, req.params, vars);
393
409
  base.resolvedUrl = resolvedUrl;
394
410
  const start = Date.now();
@@ -450,7 +466,7 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
450
466
  let consoleOutput = [];
451
467
  let postScriptError;
452
468
  if (req.postRequestScript?.trim()) {
453
- const r = await requestHandler.runScript(req.postRequestScript, {
469
+ const r = await requestHandler.runScript(requestHandler.interpolate(req.postRequestScript, vars), {
454
470
  envVars: updatedEnvVars,
455
471
  collectionVars: updatedCollectionVars,
456
472
  globals: updatedGlobals,
@@ -469,8 +485,19 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
469
485
  if (verbose && r.consoleOutput.length) r.consoleOutput.forEach((l) => console.log(color(` [post] ${l}`, C.gray)));
470
486
  }
471
487
  const allPassed = testResults.every((t) => t.passed);
472
- const httpOk = fetchResp.status < 400;
473
- const status = postScriptError ? "error" : testResults.length > 0 ? allPassed ? "passed" : "failed" : httpOk ? "passed" : "failed";
488
+ const httpFailed = fetchResp.status >= 400;
489
+ const hasTests = testResults.length > 0;
490
+ const status = postScriptError ? "error" : httpFailed ? "failed" : hasTests ? allPassed ? "passed" : "failed" : "skipped";
491
+ if (httpFailed && !testResults.some((t) => !t.passed)) {
492
+ testResults = [
493
+ ...testResults,
494
+ {
495
+ name: `HTTP status ${fetchResp.status} ${fetchResp.statusText}`.trim(),
496
+ passed: false,
497
+ error: `Request returned ${fetchResp.status} — no assertion was defined to verify the status code.`
498
+ }
499
+ ];
500
+ }
474
501
  const reqHeaders = {};
475
502
  headers.forEach((v, k) => {
476
503
  reqHeaders[k] = v;
@@ -510,7 +537,7 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
510
537
  }
511
538
  }
512
539
  function printResult(r, verbose) {
513
- const icon = r.status === "passed" ? color("✓", C.green, C.bold) : r.status === "failed" ? color("✗", C.red, C.bold) : color("⚠", C.yellow, C.bold);
540
+ const icon = r.status === "passed" ? color("✓", C.green, C.bold) : r.status === "failed" ? color("✗", C.red, C.bold) : r.status === "skipped" ? color("○", C.gray, C.bold) : color("⚠", C.yellow, C.bold);
514
541
  const http = r.httpStatus ? color(` ${r.httpStatus}`, r.httpStatus < 400 ? C.green : C.red) : "";
515
542
  const dur = r.durationMs !== void 0 ? color(` ${r.durationMs}ms`, C.gray) : "";
516
543
  const method = color(r.method.padEnd(7), C.cyan);
@@ -587,7 +614,7 @@ async function main() {
587
614
  } : void 0
588
615
  };
589
616
  }
590
- const summary = { total: 0, passed: 0, failed: 0, errors: 0, durationMs: 0 };
617
+ const summary = { total: 0, passed: 0, failed: 0, errors: 0, skipped: 0, durationMs: 0 };
591
618
  const allResults = [];
592
619
  const totalStart = Date.now();
593
620
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
@@ -605,6 +632,7 @@ async function main() {
605
632
  const workspaceTls = workspace.settings?.tls;
606
633
  const effectiveTls = col.tls ? { ...workspaceTls, ...col.tls } : workspaceTls;
607
634
  let bailed = false;
635
+ let lastPrintedScope = null;
608
636
  for (const item of items) {
609
637
  const { result, updatedEnvVars, updatedCollectionVars, updatedGlobals, updatedLocalVars } = await executeRequest(
610
638
  item.request,
@@ -619,11 +647,18 @@ async function main() {
619
647
  runCollectionVars = updatedCollectionVars;
620
648
  runGlobals = updatedGlobals;
621
649
  runLocalVars = updatedLocalVars;
650
+ result.scopePath = item.scopePath;
651
+ const scopeKey = (item.scopePath ?? []).join(" / ");
652
+ if (scopeKey !== lastPrintedScope) {
653
+ if (scopeKey) console.log(color(` ${scopeKey}`, C.gray, C.bold));
654
+ lastPrintedScope = scopeKey;
655
+ }
622
656
  printResult(result, verbose);
623
657
  allResults.push(result);
624
658
  summary.total++;
625
659
  if (result.status === "passed") summary.passed++;
626
660
  else if (result.status === "failed") summary.failed++;
661
+ else if (result.status === "skipped") summary.skipped++;
627
662
  else summary.errors++;
628
663
  if (bail && (result.status === "failed" || result.status === "error")) {
629
664
  console.log(color("\n Bailing after first failure.", C.yellow));
@@ -638,8 +673,9 @@ async function main() {
638
673
  const passStr = color(`${summary.passed} passed`, C.green, C.bold);
639
674
  const failStr = summary.failed > 0 ? color(` · ${summary.failed} failed`, C.red, C.bold) : "";
640
675
  const errStr = summary.errors > 0 ? color(` · ${summary.errors} errors`, C.yellow, C.bold) : "";
676
+ const skipStr = summary.skipped > 0 ? color(` · ${summary.skipped} skipped`, C.gray, C.bold) : "";
641
677
  const totalStr = color(` · ${summary.total} total · ${summary.durationMs}ms`, C.gray);
642
- console.log(` ${passStr}${failStr}${errStr}${totalStr}
678
+ console.log(` ${passStr}${failStr}${errStr}${skipStr}${totalStr}
643
679
  `);
644
680
  if (outputPath) {
645
681
  const meta = { workspace: wsPath, environment: env?.name ?? null, collection: firstColName, timestamp };
@@ -36,6 +36,10 @@ const api = {
36
36
  importOpenApiFromUrl: (url) => electron.ipcRenderer.invoke("import:openapi-url", url),
37
37
  importInsomnia: () => electron.ipcRenderer.invoke("import:insomnia"),
38
38
  importBruno: () => electron.ipcRenderer.invoke("import:bruno"),
39
+ /** Extract response-body schemas from an OpenAPI spec file (no full import). */
40
+ extractOpenApiSchemas: () => electron.ipcRenderer.invoke("import:openapi-schemas"),
41
+ /** Extract response-body schemas from an OpenAPI spec URL (no full import). */
42
+ extractOpenApiSchemasFromUrl: (url) => electron.ipcRenderer.invoke("import:openapi-schemas-url", url),
39
43
  // ─── Code generation ──────────────────────────────────────────────────────
40
44
  generateCode: (opts) => electron.ipcRenderer.invoke("generate:code", opts),
41
45
  pickOutputDir: () => electron.ipcRenderer.invoke("dialog:pickDir"),