@testsmith/api-spector 0.1.8 → 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.
@@ -30,6 +30,7 @@ const vm = require("vm");
30
30
  const tv4 = require("tv4");
31
31
  const jsonpathPlus = require("jsonpath-plus");
32
32
  const xmldom = require("@xmldom/xmldom");
33
+ const Ajv = require("ajv");
33
34
  function _interopNamespaceDefault(e) {
34
35
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
35
36
  if (e) {
@@ -46,6 +47,7 @@ function _interopNamespaceDefault(e) {
46
47
  n.default = e;
47
48
  return Object.freeze(n);
48
49
  }
50
+ const crypto__namespace = /* @__PURE__ */ _interopNamespaceDefault(crypto);
49
51
  const vm__namespace = /* @__PURE__ */ _interopNamespaceDefault(vm);
50
52
  let globals = {};
51
53
  let currentDir = null;
@@ -185,11 +187,27 @@ function interpolate(str, vars) {
185
187
  });
186
188
  }
187
189
  function buildUrl(baseUrl, params, vars) {
188
- const url = interpolate(baseUrl, vars);
190
+ const templateTokens = /* @__PURE__ */ new Set();
191
+ baseUrl.replace(/\{\{([^}]+)\}\}/g, (_m, name) => {
192
+ templateTokens.add(String(name).trim());
193
+ return "";
194
+ });
189
195
  const enabled = params.filter((p) => p.enabled && p.key);
190
- if (!enabled.length) return url;
196
+ const pathRows = [];
197
+ const queryRows = [];
198
+ for (const p of enabled) {
199
+ const isPath = p.paramType === "path" || templateTokens.has(p.key);
200
+ if (isPath) pathRows.push(p);
201
+ else queryRows.push(p);
202
+ }
203
+ const mergedVars = pathRows.length ? {
204
+ ...vars,
205
+ ...Object.fromEntries(pathRows.map((p) => [p.key, interpolate(p.value, vars)]))
206
+ } : vars;
207
+ const url = interpolate(baseUrl, mergedVars);
208
+ if (!queryRows.length) return url;
191
209
  const sep = url.includes("?") ? "&" : "?";
192
- const qs = enabled.map((p) => `${encodeURIComponent(interpolate(p.key, vars))}=${encodeURIComponent(interpolate(p.value, vars))}`).join("&");
210
+ const qs = queryRows.map((p) => `${encodeURIComponent(interpolate(p.key, vars))}=${encodeURIComponent(interpolate(p.value, vars))}`).join("&");
193
211
  return url + sep + qs;
194
212
  }
195
213
  async function buildEnvVars(environment) {
@@ -442,7 +460,44 @@ function buildAt(ctx, testResults, consoleOutput) {
442
460
  // Expect / assertions
443
461
  expect: (value) => makeAsserter(value, false),
444
462
  // JSONPath query: sp.jsonPath(data, '$.store.book[?(@.price < 10)].title')
445
- jsonPath: (data, expr) => jsonpathPlus.JSONPath({ path: expr, json: data })
463
+ jsonPath: (data, expr) => jsonpathPlus.JSONPath({ path: expr, json: data }),
464
+ /**
465
+ * Generate a TOTP code from a base32-encoded secret.
466
+ *
467
+ * Usage in scripts:
468
+ * const code = sp.totp("JBSWY3DPEHPK3PXP");
469
+ * sp.environment.set("otp", code);
470
+ *
471
+ * Options (all optional):
472
+ * digits — code length (default 6)
473
+ * period — time step in seconds (default 30)
474
+ * algorithm — "sha1" | "sha256" | "sha512" (default "sha1")
475
+ */
476
+ totp: (secret, options) => {
477
+ const digits = options?.digits ?? 6;
478
+ const period = options?.period ?? 30;
479
+ const algorithm = options?.algorithm ?? "sha1";
480
+ const base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
481
+ const cleaned = secret.replace(/[\s=-]/g, "").toUpperCase();
482
+ let bits = "";
483
+ for (const c of cleaned) {
484
+ const val = base32chars.indexOf(c);
485
+ if (val < 0) throw new Error(`Invalid base32 character: ${c}`);
486
+ bits += val.toString(2).padStart(5, "0");
487
+ }
488
+ const keyBytes = Buffer.alloc(Math.floor(bits.length / 8));
489
+ for (let i = 0; i < keyBytes.length; i++) {
490
+ keyBytes[i] = parseInt(bits.slice(i * 8, i * 8 + 8), 2);
491
+ }
492
+ const counter = Math.floor(Date.now() / 1e3 / period);
493
+ const counterBuf = Buffer.alloc(8);
494
+ counterBuf.writeUInt32BE(Math.floor(counter / 4294967296), 0);
495
+ counterBuf.writeUInt32BE(counter >>> 0, 4);
496
+ const hmac = crypto__namespace.createHmac(algorithm, keyBytes).update(counterBuf).digest();
497
+ const offset = hmac[hmac.length - 1] & 15;
498
+ const binCode = (hmac[offset] & 127) << 24 | (hmac[offset + 1] & 255) << 16 | (hmac[offset + 2] & 255) << 8 | hmac[offset + 3] & 255;
499
+ return String(binCode % 10 ** digits).padStart(digits, "0");
500
+ }
446
501
  };
447
502
  if (ctx.response) {
448
503
  const resp = ctx.response;
@@ -695,6 +750,36 @@ async function fetchOAuth2Token(auth, vars) {
695
750
  refreshToken: json["refresh_token"] ? String(json["refresh_token"]) : void 0
696
751
  };
697
752
  }
753
+ function buildProxyUri(proxy) {
754
+ const raw = proxy.url.trim();
755
+ if (!raw) throw new Error("Proxy URL is empty");
756
+ const normalized = normalizeProxyInput(raw);
757
+ const parsed = new URL(normalized);
758
+ if (proxy.auth && (proxy.auth.username || proxy.auth.password)) {
759
+ parsed.username = proxy.auth.username;
760
+ parsed.password = proxy.auth.password;
761
+ }
762
+ return parsed.toString();
763
+ }
764
+ function normalizeProxyInput(input) {
765
+ if (input.includes("=")) {
766
+ const entries = input.split(";").map((part) => part.trim()).filter(Boolean);
767
+ const map = /* @__PURE__ */ new Map();
768
+ for (const part of entries) {
769
+ const idx = part.indexOf("=");
770
+ if (idx <= 0 || idx === part.length - 1) continue;
771
+ const key = part.slice(0, idx).trim().toLowerCase();
772
+ const value = part.slice(idx + 1).trim();
773
+ if (value) map.set(key, value);
774
+ }
775
+ const picked = map.get("https") ?? map.get("http") ?? map.values().next().value;
776
+ if (picked) return ensureScheme(picked);
777
+ }
778
+ return ensureScheme(input);
779
+ }
780
+ function ensureScheme(value) {
781
+ return /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value) ? value : `http://${value}`;
782
+ }
698
783
  function maskPii(data, patterns) {
699
784
  if (!patterns.length) return data;
700
785
  try {
@@ -734,6 +819,102 @@ function maskHeaders(headers, patterns) {
734
819
  }
735
820
  return result;
736
821
  }
822
+ function asObject(value) {
823
+ return value && typeof value === "object" ? value : null;
824
+ }
825
+ function readStringField(obj, key) {
826
+ const value = obj?.[key];
827
+ return typeof value === "string" && value ? value : void 0;
828
+ }
829
+ function safeProxySummary(proxy) {
830
+ if (!proxy?.url?.trim()) return "off";
831
+ try {
832
+ const normalized = buildProxyUri({ url: proxy.url });
833
+ const parsed = new URL(normalized);
834
+ const host = parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname;
835
+ const auth = proxy.auth ? "yes" : "no";
836
+ return `${parsed.protocol}//${host} auth=${auth}`;
837
+ } catch {
838
+ return `invalid input "${proxy.url}"`;
839
+ }
840
+ }
841
+ function safeTlsSummary(tls) {
842
+ if (!tls) return "off";
843
+ const parts = [];
844
+ if (tls.rejectUnauthorized !== void 0) parts.push(`rejectUnauthorized=${String(tls.rejectUnauthorized)}`);
845
+ if (tls.caCertPath) parts.push(`ca=${tls.caCertPath}`);
846
+ if (tls.clientCertPath) parts.push(`cert=${tls.clientCertPath}`);
847
+ if (tls.clientKeyPath) parts.push(`key=${tls.clientKeyPath}`);
848
+ return parts.length ? parts.join(", ") : "on";
849
+ }
850
+ function formatRequestError(err, context) {
851
+ const obj = asObject(err);
852
+ const message = err instanceof Error ? err.message : String(err);
853
+ const code = readStringField(obj, "code");
854
+ const stack = err instanceof Error ? err.stack : void 0;
855
+ const causeObj = obj ? asObject(obj["cause"]) : null;
856
+ const causeMessage = readStringField(causeObj, "message");
857
+ const causeCode = readStringField(causeObj, "code");
858
+ const lines = [
859
+ `[request:send] ${context.method} ${context.resolvedUrl}`,
860
+ `[request:send] requestId=${context.requestId}`,
861
+ `[request:send] proxy=${safeProxySummary(context.proxy)}`,
862
+ `[request:send] tls=${safeTlsSummary(context.tls)}`,
863
+ `[request:send] error=${message}${code ? ` (code=${code})` : ""}`
864
+ ];
865
+ if (causeMessage) {
866
+ lines.push(`[request:send] cause=${causeMessage}${causeCode ? ` (code=${causeCode})` : ""}`);
867
+ }
868
+ if (stack) {
869
+ const preview = stack.split("\n").slice(0, 6).join("\n");
870
+ lines.push("[request:send] stack:");
871
+ lines.push(preview);
872
+ }
873
+ return lines.join("\n");
874
+ }
875
+ const schemaAjv = new Ajv({ allErrors: true, strict: false });
876
+ function buildSchemaTestResults(schemaText, body) {
877
+ const trimmed = schemaText?.trim();
878
+ if (!trimmed) return [];
879
+ let schema;
880
+ try {
881
+ schema = JSON.parse(trimmed);
882
+ } catch {
883
+ return [{
884
+ name: "[schema] body matches schema",
885
+ passed: false,
886
+ error: "Schema is not valid JSON"
887
+ }];
888
+ }
889
+ let data;
890
+ try {
891
+ data = JSON.parse(body);
892
+ } catch {
893
+ return [{
894
+ name: "[schema] body matches schema",
895
+ passed: false,
896
+ error: "Response body is not valid JSON — cannot validate against schema"
897
+ }];
898
+ }
899
+ let validate;
900
+ try {
901
+ validate = schemaAjv.compile(schema);
902
+ } catch (e) {
903
+ return [{
904
+ name: "[schema] body matches schema",
905
+ passed: false,
906
+ error: `Schema compile error: ${e instanceof Error ? e.message : String(e)}`
907
+ }];
908
+ }
909
+ if (validate(data)) {
910
+ return [{ name: "[schema] body matches schema", passed: true }];
911
+ }
912
+ return (validate.errors ?? []).map((err) => ({
913
+ name: `[schema] body${err.instancePath ? ` at ${err.instancePath}` : ""}`,
914
+ passed: false,
915
+ error: err.message ?? "Schema violation"
916
+ }));
917
+ }
737
918
  async function buildDispatcher(proxy, tls) {
738
919
  const connectOpts = {};
739
920
  let hasTls = false;
@@ -762,10 +943,10 @@ async function buildDispatcher(proxy, tls) {
762
943
  }
763
944
  }
764
945
  if (proxy?.url) {
765
- const proxyUri = proxy.auth ? proxy.url.replace("://", `://${encodeURIComponent(proxy.auth.username)}:${encodeURIComponent(proxy.auth.password)}@`) : proxy.url;
766
946
  return new undici.ProxyAgent({
767
947
  uri: proxyUri,
768
- ...hasTls ? { connect: connectOpts } : {}
948
+ requestTls: proxyConnect,
949
+ proxyTls: proxyConnect
769
950
  });
770
951
  }
771
952
  if (hasTls) {
@@ -808,7 +989,7 @@ function registerRequestHandler(ipc) {
808
989
  let updatedEnvVars = { ...envVars };
809
990
  let updatedGlobals = { ...mergedGlobals };
810
991
  if (req.preRequestScript?.trim()) {
811
- const result = await runScript(req.preRequestScript, {
992
+ const result = await runScript(interpolate(req.preRequestScript, vars), {
812
993
  envVars: { ...envVars },
813
994
  collectionVars: { ...collectionVars },
814
995
  globals: { ...mergedGlobals },
@@ -968,6 +1149,14 @@ function registerRequestHandler(ipc) {
968
1149
  durationMs
969
1150
  };
970
1151
  } catch (err) {
1152
+ const diagnostic = formatRequestError(err, {
1153
+ requestId: req.id,
1154
+ method: req.method,
1155
+ resolvedUrl,
1156
+ proxy,
1157
+ tls
1158
+ });
1159
+ console.error(diagnostic);
971
1160
  response = {
972
1161
  status: 0,
973
1162
  statusText: "Error",
@@ -975,14 +1164,15 @@ function registerRequestHandler(ipc) {
975
1164
  body: "",
976
1165
  bodySize: 0,
977
1166
  durationMs: Date.now() - start,
978
- error: err instanceof Error ? err.message : String(err)
1167
+ error: diagnostic
979
1168
  };
980
1169
  }
1170
+ const schemaTestResults = !response.error ? buildSchemaTestResults(req.schema, response.body) : [];
981
1171
  let postTestResults = [];
982
1172
  let postConsole = [];
983
1173
  let postError;
984
1174
  if (req.postRequestScript?.trim() && !response.error) {
985
- const result = await runScript(req.postRequestScript, {
1175
+ const result = await runScript(interpolate(req.postRequestScript, vars), {
986
1176
  envVars: { ...updatedEnvVars },
987
1177
  collectionVars: { ...updatedCollectionVars },
988
1178
  globals: { ...updatedGlobals },
@@ -999,8 +1189,16 @@ function registerRequestHandler(ipc) {
999
1189
  patchGlobals(result.updatedGlobals);
1000
1190
  await persistGlobals();
1001
1191
  }
1192
+ const combinedTestResults = [...schemaTestResults, ...postTestResults];
1193
+ if (!response.error && response.status >= 400 && !combinedTestResults.some((t) => !t.passed)) {
1194
+ combinedTestResults.push({
1195
+ name: `HTTP status ${response.status} ${response.statusText}`.trim(),
1196
+ passed: false,
1197
+ error: `Request returned ${response.status} — no assertion was defined to verify the status code.`
1198
+ });
1199
+ }
1002
1200
  const scriptResult = {
1003
- testResults: postTestResults,
1201
+ testResults: combinedTestResults,
1004
1202
  consoleOutput: [...decryptionWarnings, ...preScriptMeta.consoleOutput, ...postConsole],
1005
1203
  updatedEnvVars,
1006
1204
  updatedCollectionVars,
@@ -1030,6 +1228,7 @@ exports.buildAuthHeaders = buildAuthHeaders;
1030
1228
  exports.buildDispatcher = buildDispatcher;
1031
1229
  exports.buildDynamicVars = buildDynamicVars;
1032
1230
  exports.buildEnvVars = buildEnvVars;
1231
+ exports.buildSchemaTestResults = buildSchemaTestResults;
1033
1232
  exports.buildUrl = buildUrl;
1034
1233
  exports.fetchOAuth2Token = fetchOAuth2Token;
1035
1234
  exports.getGlobals = getGlobals;
package/out/main/index.js CHANGED
@@ -25,7 +25,7 @@ const electron = require("electron");
25
25
  const path = require("path");
26
26
  const fs = require("fs");
27
27
  const promises = require("fs/promises");
28
- const requestHandler = require("./chunks/request-handler-BEFvdSz6.js");
28
+ const requestHandler = require("./chunks/request-handler-AFBOd__c.js");
29
29
  const uuid = require("uuid");
30
30
  const jsYaml = require("js-yaml");
31
31
  const undici = require("undici");
@@ -458,6 +458,39 @@ function schemaToExample(schema) {
458
458
  return null;
459
459
  }
460
460
  }
461
+ function buildResponseSchema(operation, spec) {
462
+ const responses = operation.responses;
463
+ if (!responses || typeof responses !== "object") return void 0;
464
+ const codes = Object.keys(responses);
465
+ const ordered = [];
466
+ if (codes.includes("200")) ordered.push("200");
467
+ if (codes.includes("201")) ordered.push("201");
468
+ for (const code of codes.sort()) {
469
+ if (/^2\d\d$/.test(code) && !ordered.includes(code)) ordered.push(code);
470
+ }
471
+ for (const code of codes) {
472
+ if (/^2xx$/i.test(code) && !ordered.includes(code)) ordered.push(code);
473
+ }
474
+ if (codes.includes("default")) ordered.push("default");
475
+ for (const code of ordered) {
476
+ const responseObj = resolve(spec, responses[code]);
477
+ const content = responseObj?.content;
478
+ if (!content || typeof content !== "object") continue;
479
+ const mediaKeys = Object.keys(content);
480
+ const jsonKey = mediaKeys.find((k) => k.toLowerCase().split(";")[0].trim() === "application/json") ?? mediaKeys.find((k) => /[+/]json(\b|;)/i.test(k)) ?? mediaKeys.find((k) => k.toLowerCase().includes("json"));
481
+ if (!jsonKey) continue;
482
+ const rawSchema = content[jsonKey]?.schema;
483
+ if (!rawSchema) continue;
484
+ const resolved = resolve(spec, rawSchema);
485
+ if (!resolved || typeof resolved === "object" && Object.keys(resolved).length === 0) continue;
486
+ try {
487
+ return JSON.stringify(resolved, null, 2);
488
+ } catch {
489
+ return void 0;
490
+ }
491
+ }
492
+ return void 0;
493
+ }
461
494
  function buildBody(operation, spec) {
462
495
  const content = resolve(spec, operation.requestBody?.content ?? {});
463
496
  if ("application/json" in content) {
@@ -475,6 +508,39 @@ function buildParams(operation) {
475
508
  description: p.description ?? ""
476
509
  }));
477
510
  }
511
+ function buildPathParamRows(operation) {
512
+ return (operation.parameters ?? []).filter((p) => p.in === "path" && p.name).map((p) => {
513
+ const schema = p.schema ?? {};
514
+ let value;
515
+ if (p.example !== void 0) value = p.example;
516
+ else if (schema.example !== void 0) value = schema.example;
517
+ else if (schema.default !== void 0) value = schema.default;
518
+ else if (Array.isArray(schema.enum) && schema.enum.length) value = schema.enum[0];
519
+ else {
520
+ switch (schema.type) {
521
+ case "integer":
522
+ case "number":
523
+ value = 1;
524
+ break;
525
+ case "boolean":
526
+ value = true;
527
+ break;
528
+ default:
529
+ value = "";
530
+ }
531
+ }
532
+ return {
533
+ key: String(p.name),
534
+ value: value === null || value === void 0 ? "" : String(value),
535
+ enabled: true,
536
+ description: p.description ?? "",
537
+ paramType: "path"
538
+ };
539
+ });
540
+ }
541
+ function rewritePathTemplate(url) {
542
+ return url.replace(/\{([^/{}]+)\}/g, (_m, name) => `{{${name}}}`);
543
+ }
478
544
  function buildHeaders(operation) {
479
545
  return (operation.parameters ?? []).filter((p) => p.in === "header").map((p) => ({
480
546
  key: p.name,
@@ -524,17 +590,24 @@ function buildCollection(spec) {
524
590
  const allParams = [...pathLevelParams, ...operation.parameters ?? []];
525
591
  const opWithParams = { ...operation, parameters: allParams };
526
592
  const security = operation.security ?? globalSecurity;
593
+ const params = [
594
+ ...buildPathParamRows(opWithParams),
595
+ ...buildParams(opWithParams)
596
+ ];
597
+ const rawOperation = pathItem[method];
598
+ const responseSchema = buildResponseSchema(rawOperation, spec);
527
599
  const req = {
528
600
  id: uuid.v4(),
529
601
  name: operation.summary ?? operation.operationId ?? `${method.toUpperCase()} ${pathStr}`,
530
602
  method: method.toUpperCase(),
531
- url: `${baseUrl}${pathStr}`,
603
+ url: rewritePathTemplate(`${baseUrl}${pathStr}`),
532
604
  headers: buildHeaders(opWithParams),
533
- params: buildParams(opWithParams),
605
+ params,
534
606
  auth: buildAuth(security, securitySchemes),
535
607
  body: buildBody(opWithParams, spec),
536
608
  description: operation.description ?? "",
537
- meta: { tags }
609
+ meta: { tags },
610
+ ...responseSchema ? { schema: responseSchema } : {}
538
611
  };
539
612
  requests[req.id] = req;
540
613
  if (!foldersByTag[tag]) {
@@ -558,6 +631,32 @@ async function importOpenApi(filePath) {
558
631
  async function importOpenApiFromUrl(url) {
559
632
  return buildCollection(await loadSpecFromUrl(url));
560
633
  }
634
+ function extractSchemas(spec) {
635
+ const entries = [];
636
+ for (const [pathStr, pathItem] of Object.entries(spec.paths ?? {})) {
637
+ for (const method of HTTP_METHODS$1) {
638
+ const operation = pathItem?.[method];
639
+ if (!operation) continue;
640
+ const schema = buildResponseSchema(operation, spec);
641
+ if (!schema) continue;
642
+ entries.push({
643
+ method: method.toUpperCase(),
644
+ pathTemplate: pathStr,
645
+ pathRewritten: rewritePathTemplate(pathStr),
646
+ schema,
647
+ operationId: operation.operationId,
648
+ summary: operation.summary
649
+ });
650
+ }
651
+ }
652
+ return entries;
653
+ }
654
+ async function extractSchemasFromFile(filePath) {
655
+ return extractSchemas(await loadSpec$1(filePath));
656
+ }
657
+ async function extractSchemasFromUrl(url) {
658
+ return extractSchemas(await loadSpecFromUrl(url));
659
+ }
561
660
  function parseHeaders(headers) {
562
661
  return (headers ?? []).map((h) => ({
563
662
  key: h.name ?? "",
@@ -871,6 +970,18 @@ function registerImportHandlers(ipc) {
871
970
  if (result.canceled || !result.filePaths[0]) return null;
872
971
  return importBruno(result.filePaths[0]);
873
972
  });
973
+ ipc.handle("import:openapi-schemas", async () => {
974
+ const result = await electron.dialog.showOpenDialog({
975
+ title: "Load OpenAPI spec for schema sync",
976
+ filters: [{ name: "OpenAPI", extensions: ["json", "yaml", "yml"] }],
977
+ properties: ["openFile"]
978
+ });
979
+ if (result.canceled || !result.filePaths[0]) return null;
980
+ return extractSchemasFromFile(result.filePaths[0]);
981
+ });
982
+ ipc.handle("import:openapi-schemas-url", async (_event, url) => {
983
+ return extractSchemasFromUrl(url);
984
+ });
874
985
  }
875
986
  function safeName(name) {
876
987
  return name.replace(/[^\w\s]/g, " ").split(/\s+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
@@ -2219,10 +2330,10 @@ async function buildDispatcher(proxy, tls) {
2219
2330
  }
2220
2331
  }
2221
2332
  if (proxy?.url) {
2222
- const proxyUri = proxy.auth ? proxy.url.replace("://", `://${encodeURIComponent(proxy.auth.username)}:${encodeURIComponent(proxy.auth.password)}@`) : proxy.url;
2223
2333
  return new undici.ProxyAgent({
2224
2334
  uri: proxyUri,
2225
- ...hasTls ? { connect: connectOpts } : {}
2335
+ requestTls: proxyConnect,
2336
+ proxyTls: proxyConnect
2226
2337
  });
2227
2338
  }
2228
2339
  if (hasTls) return new undici.Agent({ connect: connectOpts });
@@ -2242,7 +2353,7 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
2242
2353
  let updatedGlobals = { ...globals };
2243
2354
  let preScriptError;
2244
2355
  if (req.preRequestScript?.trim()) {
2245
- const r = await requestHandler.runScript(req.preRequestScript, {
2356
+ const r = await requestHandler.runScript(requestHandler.interpolate(req.preRequestScript, vars), {
2246
2357
  envVars: { ...envVars },
2247
2358
  collectionVars: { ...collectionVars },
2248
2359
  globals: { ...globals },
@@ -2323,18 +2434,19 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
2323
2434
  bodySize: Buffer.byteLength(responseBody, "utf8"),
2324
2435
  durationMs
2325
2436
  };
2326
- let testResults = [];
2437
+ const schemaTestResults = requestHandler.buildSchemaTestResults(req.schema, responseBody);
2438
+ let testResults = [...schemaTestResults];
2327
2439
  let consoleOutput = [];
2328
2440
  let postScriptError;
2329
2441
  if (req.postRequestScript?.trim()) {
2330
- const r = await requestHandler.runScript(req.postRequestScript, {
2442
+ const r = await requestHandler.runScript(requestHandler.interpolate(req.postRequestScript, vars), {
2331
2443
  envVars: updatedEnvVars,
2332
2444
  collectionVars: updatedCollectionVars,
2333
2445
  globals: updatedGlobals,
2334
2446
  localVars,
2335
2447
  response
2336
2448
  });
2337
- testResults = r.testResults;
2449
+ testResults = [...schemaTestResults, ...r.testResults];
2338
2450
  consoleOutput = r.consoleOutput;
2339
2451
  postScriptError = r.error;
2340
2452
  updatedEnvVars = r.updatedEnvVars;
@@ -2345,7 +2457,19 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
2345
2457
  await requestHandler.persistGlobals();
2346
2458
  }
2347
2459
  const allPassed = testResults.every((t) => t.passed);
2348
- const status = postScriptError ? "error" : testResults.length > 0 ? allPassed ? "passed" : "failed" : "passed";
2460
+ const httpFailed = fetchResp.status >= 400;
2461
+ const hasTests = testResults.length > 0;
2462
+ const status = postScriptError ? "error" : httpFailed ? "failed" : hasTests ? allPassed ? "passed" : "failed" : "skipped";
2463
+ if (httpFailed && !testResults.some((t) => !t.passed)) {
2464
+ testResults = [
2465
+ ...testResults,
2466
+ {
2467
+ name: `HTTP status ${fetchResp.status} ${fetchResp.statusText}`.trim(),
2468
+ passed: false,
2469
+ error: `Request returned ${fetchResp.status} — no assertion was defined to verify the status code.`
2470
+ }
2471
+ ];
2472
+ }
2349
2473
  const sentHeaders = {};
2350
2474
  headers.forEach((v, k) => {
2351
2475
  sentHeaders[k] = v;
@@ -2379,7 +2503,7 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
2379
2503
  ...base,
2380
2504
  status: "error",
2381
2505
  durationMs: Date.now() - start,
2382
- error: err instanceof Error ? err.message : String(err),
2506
+ error: err instanceof Error ? err.cause instanceof Error ? `${err.message}: ${err.cause.message}` : err.message : String(err),
2383
2507
  preScriptError
2384
2508
  },
2385
2509
  updatedEnvVars,
@@ -2397,7 +2521,7 @@ function registerRunnerHandler(ipc) {
2397
2521
  const liveGlobals = requestHandler.getGlobals();
2398
2522
  const globals = { ...payloadGlobals, ...liveGlobals };
2399
2523
  const dispatcher = await buildDispatcher(proxy, tls);
2400
- const summary = { total: items.length, passed: 0, failed: 0, errors: 0, durationMs: 0 };
2524
+ const summary = { total: items.length, passed: 0, failed: 0, errors: 0, skipped: 0, durationMs: 0 };
2401
2525
  const totalStart = Date.now();
2402
2526
  let runEnvVars = { ...envVars };
2403
2527
  let runCollectionVars = {};
@@ -2440,6 +2564,7 @@ function registerRunnerHandler(ipc) {
2440
2564
  isHook: item.isHook,
2441
2565
  hookType: item.hookType,
2442
2566
  scopeId: item.scopeId,
2567
+ scopePath: item.scopePath,
2443
2568
  iterationLabel: item.iterationLabel
2444
2569
  };
2445
2570
  summary.failed++;
@@ -2451,7 +2576,8 @@ function registerRunnerHandler(ipc) {
2451
2576
  iterationLabel: item.iterationLabel,
2452
2577
  isHook: item.isHook,
2453
2578
  hookType: item.hookType,
2454
- scopeId: item.scopeId
2579
+ scopeId: item.scopeId,
2580
+ scopePath: item.scopePath
2455
2581
  };
2456
2582
  event.sender.send("runner:progress", { requestId: item.request.id, ...runningUpdate });
2457
2583
  const { result, updatedEnvVars, updatedCollectionVars, updatedGlobals, updatedLocalVars } = await executeOne(
@@ -2467,7 +2593,8 @@ function registerRunnerHandler(ipc) {
2467
2593
  runCollectionVars = updatedCollectionVars;
2468
2594
  runGlobals = updatedGlobals;
2469
2595
  runLocalVars = updatedLocalVars;
2470
- if (isHook && result.status !== "passed") {
2596
+ const hookFailed = result.status === "failed" || result.status === "error";
2597
+ if (isHook && hookFailed) {
2471
2598
  if (hookType === "beforeAll" && scopeId) {
2472
2599
  failedScopes.add(scopeId);
2473
2600
  } else if (hookType === "before" && mainRequestId) {
@@ -2476,13 +2603,15 @@ function registerRunnerHandler(ipc) {
2476
2603
  }
2477
2604
  if (result.status === "passed") summary.passed++;
2478
2605
  else if (result.status === "failed") summary.failed++;
2606
+ else if (result.status === "skipped") summary.skipped++;
2479
2607
  else summary.errors++;
2480
2608
  event.sender.send("runner:progress", {
2481
2609
  ...result,
2482
2610
  iterationLabel: item.iterationLabel,
2483
2611
  isHook: item.isHook,
2484
2612
  hookType: item.hookType,
2485
- scopeId: item.scopeId
2613
+ scopeId: item.scopeId,
2614
+ scopePath: item.scopePath
2486
2615
  });
2487
2616
  if (requestDelay > 0 && item !== items[items.length - 1]) {
2488
2617
  await sleep(requestDelay);
@@ -3114,7 +3243,7 @@ async function executeContract(req, vars) {
3114
3243
  async function runConsumerContracts(requests, envVars, collectionVars = {}) {
3115
3244
  const vars = { ...envVars, ...collectionVars };
3116
3245
  const contractRequests = requests.filter(
3117
- (r) => r.contract && (r.contract.statusCode !== void 0 || r.contract.bodySchema || r.contract.headers?.length)
3246
+ (r) => !r.disabled && r.contract && (r.contract.statusCode !== void 0 || r.contract.bodySchema || r.contract.headers?.length)
3118
3247
  );
3119
3248
  const start = Date.now();
3120
3249
  const results = await Promise.all(contractRequests.map((r) => executeContract(r, vars)));
@@ -3267,7 +3396,8 @@ function validateRequestAgainstSpec(spec, req, envVars, requestBaseUrl) {
3267
3396
  async function runProviderVerification(requests, envVars, specUrl, specPath, requestBaseUrl) {
3268
3397
  const spec = await loadSpec(specUrl, specPath);
3269
3398
  const start = Date.now();
3270
- const results = requests.map((req) => {
3399
+ const activeRequests = requests.filter((r) => !r.disabled);
3400
+ const results = activeRequests.map((req) => {
3271
3401
  const violations = validateRequestAgainstSpec(spec, req, envVars, requestBaseUrl);
3272
3402
  const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => envVars[k] ?? `{{${k}}}`);
3273
3403
  return {
@@ -3398,7 +3528,7 @@ async function runBidirectional(requests, envVars, collectionVars = {}, specUrl,
3398
3528
  const vars = { ...envVars, ...collectionVars };
3399
3529
  const start = Date.now();
3400
3530
  const contractRequests = requests.filter(
3401
- (r) => r.contract && (r.contract.statusCode !== void 0 || r.contract.bodySchema || r.contract.headers?.length)
3531
+ (r) => !r.disabled && r.contract && (r.contract.statusCode !== void 0 || r.contract.bodySchema || r.contract.headers?.length)
3402
3532
  );
3403
3533
  const results = await Promise.all(contractRequests.map(async (req) => {
3404
3534
  const url = requestHandler.buildUrl(req.url, req.params, vars);