@testsmith/api-spector 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 requestCollection = require("./chunks/request-collection-Dx0ZqB54.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>
@@ -130,76 +136,109 @@ function buildHtmlReport(results, summary, meta = {}) {
130
136
  <meta name="viewport" content="width=device-width, initial-scale=1">
131
137
  <title>${esc(collection)} — Test Results</title>
132
138
  <style>
139
+ /* ─── CSS custom properties for theming ──────────────────────────────── */
140
+ :root {
141
+ --bg: #0f1117; --bg-surface: #161b22; --bg-panel: #0d1117;
142
+ --border: #21262d; --border-hover:#30363d;
143
+ --text: #c9d1d9; --text-bright: #e6edf3; --text-muted: #8b949e; --text-dim: #484f58;
144
+ --green: #3fb950; --red: #f85149; --amber: #d29922; --blue: #79c0ff;
145
+ --badge-pass-bg: #0d3a1e; --badge-fail-bg: #3d1014; --badge-err-bg: #3d2a00; --badge-skip-bg: #21262d;
146
+ --hook-before-border: #7c3aed; --hook-after-border: #0e7490;
147
+ --hook-before-bg: #4c1d95; --hook-before-fg: #c4b5fd;
148
+ --hook-after-bg: #164e63; --hook-after-fg: #67e8f9;
149
+ --link: #3d7fb2;
150
+ }
151
+ html.light {
152
+ --bg: #ffffff; --bg-surface: #f6f8fa; --bg-panel: #ffffff;
153
+ --border: #d0d7de; --border-hover:#c4c9cf;
154
+ --text: #1f2328; --text-bright: #1f2328; --text-muted: #656d76; --text-dim: #8b949e;
155
+ --green: #1a7f37; --red: #cf222e; --amber: #9a6700; --blue: #0969da;
156
+ --badge-pass-bg: #dafbe1; --badge-fail-bg: #ffebe9; --badge-err-bg: #fff8c5; --badge-skip-bg: #f6f8fa;
157
+ --hook-before-border: #8b5cf6; --hook-after-border: #06b6d4;
158
+ --hook-before-bg: #ede9fe; --hook-before-fg: #6d28d9;
159
+ --hook-after-bg: #ecfeff; --hook-after-fg: #0e7490;
160
+ --link: #0969da;
161
+ }
162
+ /* ─── Base ───────────────────────────────────────────────────────────── */
133
163
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
134
- body { font-family: system-ui, sans-serif; background: #0f1117; color: #c9d1d9; font-size: 13px; line-height: 1.5; }
164
+ body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); font-size: 13px; line-height: 1.5; }
135
165
  .wrap { max-width: 1200px; margin: 0 auto; padding: 32px 24px; }
136
- h1 { font-size: 20px; font-weight: 600; color: #e6edf3; margin-bottom: 4px; }
137
- .meta-line { color: #8b949e; font-size: 11px; margin-bottom: 24px; }
166
+ h1 { font-size: 20px; font-weight: 600; color: var(--text-bright); margin-bottom: 4px; }
167
+ .meta-line { color: var(--text-muted); font-size: 11px; margin-bottom: 24px; }
168
+ /* Theme toggle */
169
+ .theme-toggle { position: fixed; top: 16px; right: 16px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; font-size: 16px; cursor: pointer; z-index: 100; line-height: 1; }
170
+ .theme-toggle:hover { border-color: var(--blue); }
171
+ /* Summary */
138
172
  .summary { display: flex; gap: 12px; margin-bottom: 28px; flex-wrap: wrap; }
139
- .stat { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px 20px; min-width: 90px; }
140
- .stat-val { font-size: 22px; font-weight: 700; color: #e6edf3; }
141
- .stat-lbl { font-size: 11px; color: #8b949e; margin-top: 2px; }
142
- .stat-pass .stat-val { color: #3fb950; }
143
- .stat-fail .stat-val { color: #f85149; }
144
- .stat-err .stat-val { color: #d29922; }
173
+ .stat { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 20px; min-width: 90px; }
174
+ .stat-val { font-size: 22px; font-weight: 700; color: var(--text-bright); }
175
+ .stat-lbl { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
176
+ .stat-pass .stat-val { color: var(--green); }
177
+ .stat-fail .stat-val { color: var(--red); }
178
+ .stat-err .stat-val { color: var(--amber); }
179
+ .stat-skip .stat-val { color: var(--text-muted); }
145
180
  /* Cards */
146
- .card { border: 1px solid #21262d; border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
181
+ .card { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
182
+ .scope-heading { margin: 18px 0 6px; padding: 4px 2px; font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }
183
+ .scope-heading::before { content: "\\25B8 "; color: var(--text-dim); }
147
184
  .card-header { display: flex; align-items: baseline; gap: 8px; padding: 10px 14px; cursor: pointer; user-select: none; }
148
- .card-header:hover { background: #161b22; }
149
- .chevron { font-size: 10px; color: #8b949e; min-width: 10px; transition: transform .15s; }
150
- .card-name { font-weight: 500; color: #e6edf3; white-space: nowrap; }
185
+ .card-header:hover { background: var(--bg-surface); }
186
+ .chevron { font-size: 10px; color: var(--text-muted); min-width: 10px; transition: transform .15s; }
187
+ .card-name { font-weight: 500; color: var(--text-bright); white-space: nowrap; }
151
188
  .card-url { font-family: monospace; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
152
- .card-body { padding: 12px 14px; border-top: 1px solid #21262d; display: flex; flex-direction: column; gap: 10px; }
189
+ .card-body { padding: 12px 14px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
153
190
  /* Badges */
154
191
  .badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; white-space: nowrap; }
155
- .badge-pass { background: #0d3a1e; color: #3fb950; }
156
- .badge-fail { background: #3d1014; color: #f85149; }
157
- .badge-err { background: #3d2a00; color: #d29922; }
158
- .method { font-family: monospace; font-size: 11px; font-weight: 700; color: #79c0ff; white-space: nowrap; }
159
- .method-badge { display: inline-block; font-family: monospace; font-size: 11px; font-weight: 700; color: #79c0ff; min-width: 52px; }
192
+ .badge-pass { background: var(--badge-pass-bg); color: var(--green); }
193
+ .badge-fail { background: var(--badge-fail-bg); color: var(--red); }
194
+ .badge-err { background: var(--badge-err-bg); color: var(--amber); }
195
+ .badge-skip { background: var(--badge-skip-bg); color: var(--text-muted); }
196
+ .method { font-family: monospace; font-size: 11px; font-weight: 700; color: var(--blue); white-space: nowrap; }
197
+ .method-badge { display: inline-block; font-family: monospace; font-size: 11px; font-weight: 700; color: var(--blue); min-width: 52px; }
160
198
  .http-badge { font-family: monospace; font-size: 11px; font-weight: 600; white-space: nowrap; }
161
- .http-ok { color: #3fb950; }
162
- .http-redir { color: #79c0ff; }
163
- .http-err { color: #f85149; }
199
+ .http-ok { color: var(--green); }
200
+ .http-redir { color: var(--blue); }
201
+ .http-err { color: var(--red); }
164
202
  .dur { font-family: monospace; font-size: 11px; white-space: nowrap; }
165
- .muted { color: #8b949e; }
203
+ .muted { color: var(--text-muted); }
166
204
  .mono { font-family: monospace; font-size: 12px; }
167
205
  /* Request / Response */
168
206
  .req-resp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
169
207
  @media (max-width: 700px) { .req-resp-grid { grid-template-columns: 1fr; } }
170
- .panel-label { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; }
171
- .panel { background: #0d1117; border: 1px solid #21262d; border-radius: 6px; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
172
- .req-line { font-family: monospace; font-size: 12px; color: #c9d1d9; word-break: break-all; }
208
+ .panel-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; }
209
+ .panel { background: var(--bg-panel); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
210
+ .req-line { font-family: monospace; font-size: 12px; color: var(--text); word-break: break-all; }
173
211
  .resp-status { font-family: monospace; font-size: 13px; font-weight: 700; }
174
- .section-label { font-size: 10px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: .04em; margin-top: 2px; }
212
+ .section-label { font-size: 10px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; margin-top: 2px; }
175
213
  .htable { width: 100%; border-collapse: collapse; }
176
214
  .htable td { font-family: monospace; font-size: 11px; padding: 1px 0; vertical-align: top; }
177
- .hk { color: #79c0ff; padding-right: 12px; white-space: nowrap; }
178
- .hv { color: #c9d1d9; word-break: break-all; }
179
- .code-block { font-family: monospace; font-size: 11px; color: #c9d1d9; background: #0d1117; border: 1px solid #21262d; border-radius: 4px; padding: 8px; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow-y: auto; }
215
+ .hk { color: var(--blue); padding-right: 12px; white-space: nowrap; }
216
+ .hv { color: var(--text); word-break: break-all; }
217
+ .code-block { font-family: monospace; font-size: 11px; color: var(--text); background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px; padding: 8px; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow-y: auto; }
180
218
  /* Tests */
181
- .section-label { font-size: 11px; font-weight: 600; color: #8b949e; }
219
+ .section-label { font-size: 11px; font-weight: 600; color: var(--text-muted); }
182
220
  .tests-wrap { display: flex; flex-direction: column; gap: 2px; }
183
221
  .test-row { font-size: 12px; display: flex; flex-wrap: wrap; gap: 4px; }
184
- .test-pass { color: #3fb950; }
185
- .test-fail { color: #f85149; }
186
- .test-err { color: #8b949e; padding-left: 16px; width: 100%; font-family: monospace; font-size: 11px; }
222
+ .test-pass { color: var(--green); }
223
+ .test-fail { color: var(--red); }
224
+ .test-err { color: var(--text-muted); padding-left: 16px; width: 100%; font-family: monospace; font-size: 11px; }
187
225
  .dot { font-weight: 700; }
188
226
  /* Console */
189
- .err-row { color: #f85149; font-size: 12px; }
227
+ .err-row { color: var(--red); font-size: 12px; }
190
228
  /* Footer */
191
- .report-footer { margin-top: 32px; text-align: center; font-size: 11px; color: #484f58; }
192
- .footer-link { color: #3d7fb2; text-decoration: none; }
229
+ .report-footer { margin-top: 32px; text-align: center; font-size: 11px; color: var(--text-dim); }
230
+ .footer-link { color: var(--link); text-decoration: none; }
193
231
  .footer-link:hover { text-decoration: underline; }
194
232
  /* Hook cards */
195
- .card-hook-before { border-left: 3px solid #7c3aed; }
196
- .card-hook-after { border-left: 3px solid #0e7490; }
233
+ .card-hook-before { border-left: 3px solid var(--hook-before-border); }
234
+ .card-hook-after { border-left: 3px solid var(--hook-after-border); }
197
235
  .hook-badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 10px; font-weight: 700; white-space: nowrap; letter-spacing: .04em; }
198
- .hook-before { background: #4c1d95; color: #c4b5fd; }
199
- .hook-after { background: #164e63; color: #67e8f9; }
236
+ .hook-before { background: var(--hook-before-bg); color: var(--hook-before-fg); }
237
+ .hook-after { background: var(--hook-after-bg); color: var(--hook-after-fg); }
200
238
  </style>
201
239
  </head>
202
240
  <body>
241
+ <button class="theme-toggle" onclick="toggleTheme()" title="Toggle dark/light mode" id="themeBtn">☀️</button>
203
242
  <div class="wrap">
204
243
  <h1>${esc(collection)}</h1>
205
244
  <div class="meta-line">Environment: ${esc(env)} &nbsp;·&nbsp; ${esc(ts)}</div>
@@ -208,11 +247,12 @@ function buildHtmlReport(results, summary, meta = {}) {
208
247
  <div class="stat stat-pass"><div class="stat-val">${summary.passed}</div><div class="stat-lbl">Passed</div></div>
209
248
  <div class="stat stat-fail"><div class="stat-val">${summary.failed}</div><div class="stat-lbl">Failed</div></div>
210
249
  <div class="stat stat-err"><div class="stat-val">${summary.errors}</div><div class="stat-lbl">Errors</div></div>
250
+ <div class="stat stat-skip"><div class="stat-val">${summary.skipped ?? 0}</div><div class="stat-lbl">No tests</div></div>
211
251
  <div class="stat"><div class="stat-val">${passRate}%</div><div class="stat-lbl">Pass rate</div></div>
212
252
  <div class="stat"><div class="stat-val">${summary.durationMs} ms</div><div class="stat-lbl">Duration</div></div>
213
253
  </div>
214
254
  <div class="cards">${cards}</div>
215
- <div class="report-footer">Generated by <a href="https://testsmith.io" target="_blank" rel="noopener" class="footer-link">Testsmith</a> · api Spector</div>
255
+ <div class="report-footer">Generated by <a href="https://testsmith.io" target="_blank" rel="noopener" class="footer-link">Testsmith</a> · API Spector</div>
216
256
  </div>
217
257
  <script>
218
258
  function toggle(i) {
@@ -222,6 +262,22 @@ function buildHtmlReport(results, summary, meta = {}) {
222
262
  body.style.display = open ? 'none' : 'block'
223
263
  ch.style.transform = open ? '' : 'rotate(90deg)'
224
264
  }
265
+ function toggleTheme() {
266
+ const html = document.documentElement
267
+ const isLight = html.classList.toggle('light')
268
+ document.getElementById('themeBtn').textContent = isLight ? '🌙' : '☀️'
269
+ try { localStorage.setItem('theme', isLight ? 'light' : 'dark') } catch {}
270
+ }
271
+ // Restore saved preference or respect OS preference
272
+ (function() {
273
+ try {
274
+ const saved = localStorage.getItem('theme')
275
+ if (saved === 'light' || (!saved && window.matchMedia('(prefers-color-scheme: light)').matches)) {
276
+ document.documentElement.classList.add('light')
277
+ document.getElementById('themeBtn').textContent = '🌙'
278
+ }
279
+ } catch {}
280
+ })()
225
281
  <\/script>
226
282
  </body>
227
283
  </html>
@@ -251,6 +307,8 @@ function buildJUnitReport(results, summary, meta = {}) {
251
307
  failures.push(` <error message="${esc(r.preScriptError)}" type="PreScriptError" />`);
252
308
  } else if (r.postScriptError) {
253
309
  failures.push(` <error message="${esc(r.postScriptError)}" type="PostScriptError" />`);
310
+ } else if (r.status === "skipped") {
311
+ failures.push(' <skipped message="No assertions defined for this request" />');
254
312
  } else if (r.testResults?.length) {
255
313
  for (const t of r.testResults) {
256
314
  if (!t.passed) {
@@ -265,32 +323,17 @@ ${failures.join("\n")}
265
323
  ` : "";
266
324
  return ` <testcase name="${name}" classname="${classname}" time="${timeSec}">${inner}</testcase>`;
267
325
  });
326
+ const skipped = summary.skipped ?? 0;
268
327
  const lines = [
269
328
  '<?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}">`,
329
+ `<testsuites name="${suiteName}" tests="${summary.total}" failures="${summary.failed}" errors="${summary.errors}" skipped="${skipped}" time="${totalSec}">`,
330
+ ` <testsuite name="${suiteName}" tests="${summary.total}" failures="${summary.failed}" errors="${summary.errors}" skipped="${skipped}" time="${totalSec}" timestamp="${ts}">`,
272
331
  ...cases,
273
332
  " </testsuite>",
274
333
  "</testsuites>"
275
334
  ];
276
335
  return lines.join("\n") + "\n";
277
336
  }
278
- function collectTagged(folder, requests, collectionVars, filterTags) {
279
- const results = [];
280
- for (const reqId of folder.requestIds) {
281
- const req = requests[reqId];
282
- if (!req || req.hookType) continue;
283
- const tags = req.meta?.tags ?? [];
284
- if (filterTags.length > 0 && !filterTags.some((t) => tags.includes(t))) continue;
285
- results.push({ request: req, collectionVars });
286
- }
287
- for (const sub of folder.folders) {
288
- const folderTags = sub.tags ?? [];
289
- const effectiveTags = filterTags.length === 0 ? filterTags : folderTags.some((t) => filterTags.includes(t)) ? [] : filterTags;
290
- results.push(...collectTagged(sub, requests, collectionVars, effectiveTags));
291
- }
292
- return results;
293
- }
294
337
  const C = {
295
338
  reset: "\x1B[0m",
296
339
  bold: "\x1B[1m",
@@ -359,6 +402,10 @@ async function loadEnvironments(workspace, dir) {
359
402
  return envs;
360
403
  }
361
404
  async function executeRequest(req, collectionVars, envVars, globals, localVars, verbose, tls) {
405
+ if (!req.headers) req.headers = [];
406
+ if (!req.params) req.params = [];
407
+ if (!req.body) req.body = { mode: "none" };
408
+ if (!req.auth) req.auth = { type: "none" };
362
409
  const base = {
363
410
  requestId: req.id,
364
411
  name: req.name,
@@ -370,8 +417,10 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
370
417
  let updatedCollectionVars = { ...collectionVars };
371
418
  let updatedGlobals = { ...globals };
372
419
  let preScriptError;
420
+ const dynamicVars = await requestCollection.buildDynamicVars();
421
+ let vars = requestCollection.mergeVars(envVars, collectionVars, globals, localVars, dynamicVars);
373
422
  if (req.preRequestScript?.trim()) {
374
- const r = await requestHandler.runScript(req.preRequestScript, {
423
+ const r = await requestCollection.runScript(requestCollection.interpolate(req.preRequestScript, vars), {
375
424
  envVars: { ...envVars },
376
425
  collectionVars: { ...collectionVars },
377
426
  globals: { ...globals },
@@ -382,42 +431,41 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
382
431
  updatedEnvVars = r.updatedEnvVars;
383
432
  updatedCollectionVars = r.updatedCollectionVars;
384
433
  updatedGlobals = r.updatedGlobals;
385
- requestHandler.patchGlobals(r.updatedGlobals);
386
- await requestHandler.persistGlobals();
434
+ requestCollection.patchGlobals(r.updatedGlobals);
435
+ await requestCollection.persistGlobals();
387
436
  if (verbose && r.consoleOutput.length) r.consoleOutput.forEach((l) => console.log(color(` [pre] ${l}`, C.gray)));
388
437
  if (r.error) console.error(color(` [pre-script error] ${r.error}`, C.red));
389
438
  }
390
- const dynamicVars = await requestHandler.buildDynamicVars();
391
- const vars = requestHandler.mergeVars(updatedEnvVars, updatedCollectionVars, updatedGlobals, localVars, dynamicVars);
392
- const resolvedUrl = requestHandler.buildUrl(req.url, req.params, vars);
439
+ vars = requestCollection.mergeVars(updatedEnvVars, updatedCollectionVars, updatedGlobals, localVars, dynamicVars);
440
+ const resolvedUrl = requestCollection.buildUrl(req.url, req.params, vars);
393
441
  base.resolvedUrl = resolvedUrl;
394
442
  const start = Date.now();
395
443
  try {
396
444
  const authH = {};
397
445
  if (req.auth.type === "bearer") {
398
446
  let token = req.auth.token ?? "";
399
- if (!token && req.auth.tokenSecretRef) token = await requestHandler.getSecret(req.auth.tokenSecretRef) ?? "";
400
- if (token) authH["Authorization"] = `Bearer ${requestHandler.interpolate(token, vars)}`;
447
+ if (!token && req.auth.tokenSecretRef) token = await requestCollection.getSecret(req.auth.tokenSecretRef) ?? "";
448
+ if (token) authH["Authorization"] = `Bearer ${requestCollection.interpolate(token, vars)}`;
401
449
  }
402
450
  const headers = new undici.Headers();
403
451
  for (const h of req.headers) {
404
- if (h.enabled && h.key) headers.set(requestHandler.interpolate(h.key, vars), requestHandler.interpolate(h.value, vars));
452
+ if (h.enabled && h.key) headers.set(requestCollection.interpolate(h.key, vars), requestCollection.interpolate(h.value, vars));
405
453
  }
406
454
  for (const [k, v] of Object.entries(authH)) headers.set(k, v);
407
455
  let body;
408
456
  if (req.body.mode === "json" && req.body.json) {
409
- body = requestHandler.interpolate(req.body.json, vars);
457
+ body = requestCollection.interpolate(req.body.json, vars);
410
458
  if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
411
459
  } else if (req.body.mode === "raw" && req.body.raw) {
412
- body = requestHandler.interpolate(req.body.raw, vars);
460
+ body = requestCollection.interpolate(req.body.raw, vars);
413
461
  if (!headers.has("content-type")) headers.set("Content-Type", req.body.rawContentType ?? "text/plain");
414
462
  } else if (req.body.mode === "graphql" && req.body.graphql) {
415
463
  const gql = req.body.graphql;
416
- const gqlBody = { query: requestHandler.interpolate(gql.query, vars) };
464
+ const gqlBody = { query: requestCollection.interpolate(gql.query, vars) };
417
465
  const rawVars = gql.variables?.trim();
418
466
  if (rawVars) {
419
467
  try {
420
- gqlBody.variables = JSON.parse(requestHandler.interpolate(rawVars, vars));
468
+ gqlBody.variables = JSON.parse(requestCollection.interpolate(rawVars, vars));
421
469
  } catch {
422
470
  }
423
471
  }
@@ -425,7 +473,7 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
425
473
  body = JSON.stringify(gqlBody);
426
474
  if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
427
475
  }
428
- const dispatcher = await requestHandler.buildDispatcher(void 0, tls);
476
+ const dispatcher = await requestCollection.buildDispatcher(void 0, tls);
429
477
  const fetchResp = await undici.fetch(resolvedUrl, {
430
478
  method: req.method,
431
479
  headers,
@@ -450,7 +498,7 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
450
498
  let consoleOutput = [];
451
499
  let postScriptError;
452
500
  if (req.postRequestScript?.trim()) {
453
- const r = await requestHandler.runScript(req.postRequestScript, {
501
+ const r = await requestCollection.runScript(requestCollection.interpolate(req.postRequestScript, vars), {
454
502
  envVars: updatedEnvVars,
455
503
  collectionVars: updatedCollectionVars,
456
504
  globals: updatedGlobals,
@@ -464,13 +512,24 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
464
512
  updatedCollectionVars = r.updatedCollectionVars;
465
513
  updatedGlobals = r.updatedGlobals;
466
514
  localVars = r.updatedLocalVars;
467
- requestHandler.patchGlobals(r.updatedGlobals);
468
- await requestHandler.persistGlobals();
515
+ requestCollection.patchGlobals(r.updatedGlobals);
516
+ await requestCollection.persistGlobals();
469
517
  if (verbose && r.consoleOutput.length) r.consoleOutput.forEach((l) => console.log(color(` [post] ${l}`, C.gray)));
470
518
  }
471
519
  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";
520
+ const httpFailed = fetchResp.status >= 400;
521
+ const hasTests = testResults.length > 0;
522
+ const status = postScriptError ? "error" : hasTests ? allPassed ? "passed" : "failed" : httpFailed ? "failed" : "skipped";
523
+ if (httpFailed && testResults.length === 0) {
524
+ testResults = [
525
+ ...testResults,
526
+ {
527
+ name: `HTTP status ${fetchResp.status} ${fetchResp.statusText}`.trim(),
528
+ passed: false,
529
+ error: `Request returned ${fetchResp.status} — no assertion was defined to verify the status code.`
530
+ }
531
+ ];
532
+ }
474
533
  const reqHeaders = {};
475
534
  headers.forEach((v, k) => {
476
535
  reqHeaders[k] = v;
@@ -510,7 +569,7 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
510
569
  }
511
570
  }
512
571
  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);
572
+ 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
573
  const http = r.httpStatus ? color(` ${r.httpStatus}`, r.httpStatus < 400 ? C.green : C.red) : "";
515
574
  const dur = r.durationMs !== void 0 ? color(` ${r.durationMs}ms`, C.gray) : "";
516
575
  const method = color(r.method.padEnd(7), C.cyan);
@@ -554,7 +613,7 @@ async function main() {
554
613
  console.error(color(`Error: could not read workspace file: ${wsPath}`, C.red));
555
614
  process.exit(1);
556
615
  }
557
- await requestHandler.loadGlobals(wsDir);
616
+ await requestCollection.loadGlobals(wsDir);
558
617
  const collections = await loadCollections(workspace, wsDir);
559
618
  const environments = await loadEnvironments(workspace, wsDir);
560
619
  const env = envName ? environments.find((e) => e.name.toLowerCase() === envName.toLowerCase()) ?? null : null;
@@ -567,7 +626,7 @@ async function main() {
567
626
  console.log(color(` Environment: ${env?.name ?? "(none)"}`, C.gray));
568
627
  if (filterTags.length) console.log(color(` Tags: ${filterTags.join(", ")}`, C.gray));
569
628
  console.log("");
570
- const envVarsSnapshot = await requestHandler.buildEnvVars(env);
629
+ const envVarsSnapshot = await requestCollection.buildEnvVars(env);
571
630
  const secretValuesToMask = (env?.variables ?? []).filter((v) => v.secret && v.enabled).map((v) => envVarsSnapshot[v.key]).filter((v) => typeof v === "string" && v.length > 0);
572
631
  function redact(s) {
573
632
  let out = s;
@@ -587,24 +646,36 @@ async function main() {
587
646
  } : void 0
588
647
  };
589
648
  }
590
- const summary = { total: 0, passed: 0, failed: 0, errors: 0, durationMs: 0 };
649
+ const summary = { total: 0, passed: 0, failed: 0, errors: 0, skipped: 0, durationMs: 0 };
591
650
  const allResults = [];
592
651
  const totalStart = Date.now();
593
652
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
594
653
  let firstColName;
595
654
  for (const col of collections) {
596
655
  if (colName && col.name.toLowerCase() !== colName.toLowerCase()) continue;
597
- const items = collectTagged(col.rootFolder, col.requests, col.collectionVariables ?? {}, filterTags);
656
+ const items = requestCollection.collectTagged(col.rootFolder, col.requests, col.collectionVariables ?? {}, filterTags);
598
657
  if (items.length === 0) continue;
599
658
  if (!firstColName) firstColName = col.name;
600
- let runEnvVars = await requestHandler.buildEnvVars(env);
601
- let runGlobals = requestHandler.getGlobals();
659
+ let runEnvVars = await requestCollection.buildEnvVars(env);
660
+ let runGlobals = requestCollection.getGlobals();
602
661
  let runCollectionVars = { ...col.collectionVariables ?? {} };
603
662
  let runLocalVars = {};
604
663
  console.log(color(` ┌ ${col.name}`, C.bold, C.white));
605
664
  const workspaceTls = workspace.settings?.tls;
606
665
  const effectiveTls = col.tls ? { ...workspaceTls, ...col.tls } : workspaceTls;
666
+ for (const item of items) {
667
+ const req = item.request;
668
+ const inherited = requestCollection.resolveInheritedAuthAndHeaders(req.id, col);
669
+ if (req.auth.type === "none" && inherited.auth && inherited.auth.type !== "none") {
670
+ req.auth = inherited.auth;
671
+ }
672
+ const inheritedHeaders = inherited.headers.filter((h) => h.enabled && h.key);
673
+ if (inheritedHeaders.length) {
674
+ req.headers = [...inheritedHeaders, ...req.headers];
675
+ }
676
+ }
607
677
  let bailed = false;
678
+ let lastPrintedScope = null;
608
679
  for (const item of items) {
609
680
  const { result, updatedEnvVars, updatedCollectionVars, updatedGlobals, updatedLocalVars } = await executeRequest(
610
681
  item.request,
@@ -619,11 +690,18 @@ async function main() {
619
690
  runCollectionVars = updatedCollectionVars;
620
691
  runGlobals = updatedGlobals;
621
692
  runLocalVars = updatedLocalVars;
693
+ result.scopePath = item.scopePath;
694
+ const scopeKey = (item.scopePath ?? []).join(" / ");
695
+ if (scopeKey !== lastPrintedScope) {
696
+ if (scopeKey) console.log(color(` ${scopeKey}`, C.gray, C.bold));
697
+ lastPrintedScope = scopeKey;
698
+ }
622
699
  printResult(result, verbose);
623
700
  allResults.push(result);
624
701
  summary.total++;
625
702
  if (result.status === "passed") summary.passed++;
626
703
  else if (result.status === "failed") summary.failed++;
704
+ else if (result.status === "skipped") summary.skipped++;
627
705
  else summary.errors++;
628
706
  if (bail && (result.status === "failed" || result.status === "error")) {
629
707
  console.log(color("\n Bailing after first failure.", C.yellow));
@@ -638,8 +716,9 @@ async function main() {
638
716
  const passStr = color(`${summary.passed} passed`, C.green, C.bold);
639
717
  const failStr = summary.failed > 0 ? color(` · ${summary.failed} failed`, C.red, C.bold) : "";
640
718
  const errStr = summary.errors > 0 ? color(` · ${summary.errors} errors`, C.yellow, C.bold) : "";
719
+ const skipStr = summary.skipped > 0 ? color(` · ${summary.skipped} skipped`, C.gray, C.bold) : "";
641
720
  const totalStr = color(` · ${summary.total} total · ${summary.durationMs}ms`, C.gray);
642
- console.log(` ${passStr}${failStr}${errStr}${totalStr}
721
+ console.log(` ${passStr}${failStr}${errStr}${skipStr}${totalStr}
643
722
  `);
644
723
  if (outputPath) {
645
724
  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"),