@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.
package/bin/cli.js CHANGED
@@ -8,7 +8,7 @@ const [, , cmd = 'ui', ...rest] = process.argv
8
8
 
9
9
  function printHelp() {
10
10
  console.log('')
11
- console.log(' api Spector — local-first API testing tool')
11
+ console.log(' API Spector — local-first API testing tool')
12
12
  console.log('')
13
13
  console.log(' Usage:')
14
14
  console.log(' api-spector ui Launch the app')
@@ -17,6 +17,9 @@ function printHelp() {
17
17
  console.log(' api-spector record --upstream <url> Record API traffic as mock stubs')
18
18
  console.log('')
19
19
  console.log(' Options:')
20
+ console.log(' api-spector agents init <name> Initialize AI agent files')
21
+ console.log(' api-spector agents list Show available agents')
22
+ console.log('')
20
23
  console.log(' api-spector run --help Show run options')
21
24
  console.log(' api-spector mock --help Show mock options')
22
25
  console.log(' api-spector record --help Show record options')
@@ -59,8 +62,15 @@ if (cmd === '--help' || cmd === '-h') {
59
62
  env: process.env,
60
63
  })
61
64
  proc.on('close', code => process.exit(code ?? 0))
65
+ } else if (cmd === 'agents') {
66
+ const agentsPath = path.join(__dirname, '..', 'out', 'main', 'agents.js')
67
+ const proc = spawn(process.execPath, [agentsPath, ...rest], {
68
+ stdio: 'inherit',
69
+ env: process.env,
70
+ })
71
+ proc.on('close', code => process.exit(code ?? 0))
62
72
  } else {
63
- console.error(`api Spector — unknown command: "${cmd}"`)
73
+ console.error(`API Spector — unknown command: "${cmd}"`)
64
74
  printHelp()
65
75
  process.exit(1)
66
76
  }
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const promises = require("fs/promises");
4
+ const path = require("path");
5
+ const C = {
6
+ reset: "\x1B[0m",
7
+ bold: "\x1B[1m",
8
+ green: "\x1B[32m",
9
+ cyan: "\x1B[36m",
10
+ yellow: "\x1B[33m",
11
+ gray: "\x1B[90m",
12
+ red: "\x1B[31m"
13
+ };
14
+ function color(text, ...codes) {
15
+ return codes.join("") + text + C.reset;
16
+ }
17
+ const AGENTS = {
18
+ claude: {
19
+ name: "Claude Code",
20
+ description: "Skills for Claude Code (.claude/skills/)",
21
+ files: [
22
+ { src: "claude/skills/api-spector-functional-tests.md", dest: ".claude/skills/api-spector-functional-tests/SKILL.md" },
23
+ { src: "claude/skills/api-spector-security-tests.md", dest: ".claude/skills/api-spector-security-tests/SKILL.md" },
24
+ { src: "claude/skills/api-spector-generate-mocks.md", dest: ".claude/skills/api-spector-generate-mocks/SKILL.md" },
25
+ { src: "claude/skills/api-spector-api-audit.md", dest: ".claude/skills/api-spector-api-audit/SKILL.md" }
26
+ ]
27
+ },
28
+ copilot: {
29
+ name: "GitHub Copilot",
30
+ description: "Instructions for Copilot (.github/copilot-instructions.md)",
31
+ files: [
32
+ { src: "copilot/copilot-instructions.md", dest: ".github/copilot-instructions.md" }
33
+ ]
34
+ },
35
+ cursor: {
36
+ name: "Cursor",
37
+ description: "Rules for Cursor (.cursor/rules/api-spector.mdc)",
38
+ files: [
39
+ { src: "cursor/rules/api-spector.mdc", dest: ".cursor/rules/api-spector.mdc" }
40
+ ]
41
+ },
42
+ windsurf: {
43
+ name: "Windsurf",
44
+ description: "Rules for Windsurf (.windsurfrules)",
45
+ files: [
46
+ { src: "windsurf/windsurfrules", dest: ".windsurfrules" }
47
+ ]
48
+ },
49
+ aider: {
50
+ name: "Aider",
51
+ description: "Conventions for Aider (conventions.md)",
52
+ files: [
53
+ { src: "aider/conventions.md", dest: "conventions.md" }
54
+ ]
55
+ }
56
+ };
57
+ function sharedDocsForAgent(agentName) {
58
+ const destDir = {
59
+ claude: ".claude/docs",
60
+ copilot: ".github/docs",
61
+ cursor: ".cursor/docs",
62
+ windsurf: ".windsurf/docs",
63
+ aider: ".aider/docs"
64
+ };
65
+ const dir = destDir[agentName] ?? ".api-spector/docs";
66
+ return [
67
+ { src: "api-spector-scripting-reference.md", dest: `${dir}/api-spector-scripting-reference.md` },
68
+ { src: "collection-file-format.md", dest: `${dir}/collection-file-format.md` },
69
+ { src: "functional-testing-guide.md", dest: `${dir}/functional-testing-guide.md` },
70
+ { src: "security-testing-guide.md", dest: `${dir}/security-testing-guide.md` }
71
+ ];
72
+ }
73
+ function getPackageRoot() {
74
+ let dir = __dirname;
75
+ for (let i = 0; i < 5; i++) {
76
+ try {
77
+ require.resolve(path.join(dir, "package.json"));
78
+ return dir;
79
+ } catch {
80
+ dir = path.dirname(dir);
81
+ }
82
+ }
83
+ return path.join(__dirname, "..", "..");
84
+ }
85
+ function getTemplatesDir() {
86
+ return path.join(getPackageRoot(), "src", "cli", "agent-templates");
87
+ }
88
+ function getDocsDir() {
89
+ return path.join(getPackageRoot(), "docs", "ai");
90
+ }
91
+ async function fileExists(path2) {
92
+ try {
93
+ await promises.stat(path2);
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+ async function copyFile(src, dest, cwd) {
100
+ const destPath = path.join(cwd, dest);
101
+ const existed = await fileExists(destPath);
102
+ await promises.mkdir(path.dirname(destPath), { recursive: true });
103
+ const content = await promises.readFile(src, "utf8");
104
+ if (existed) {
105
+ const existing = await promises.readFile(destPath, "utf8");
106
+ if (existing === content) return "exists";
107
+ }
108
+ await promises.writeFile(destPath, content, "utf8");
109
+ return existed ? "updated" : "created";
110
+ }
111
+ async function initAgent(agentName, cwd) {
112
+ const names = agentName === "all" ? Object.keys(AGENTS) : [agentName];
113
+ for (const name of names) {
114
+ const agent = AGENTS[name];
115
+ if (!agent) {
116
+ console.error(color(` Unknown agent: "${name}"`, C.red));
117
+ console.error(` Available: ${Object.keys(AGENTS).join(", ")}, all`);
118
+ process.exit(1);
119
+ }
120
+ console.log(color(`
121
+ ${agent.name}`, C.bold, C.cyan));
122
+ const templatesDir = getTemplatesDir();
123
+ for (const file of agent.files) {
124
+ const srcPath = path.join(templatesDir, file.src);
125
+ if (!await fileExists(srcPath)) {
126
+ console.log(color(` skip ${file.dest} (template not found)`, C.yellow));
127
+ continue;
128
+ }
129
+ const result = await copyFile(srcPath, file.dest, cwd);
130
+ const icon = result === "created" ? color("+", C.green) : result === "updated" ? color("~", C.yellow) : color("=", C.gray);
131
+ const label = result === "exists" ? "unchanged" : result;
132
+ console.log(` ${icon} ${file.dest} ${color(`(${label})`, C.gray)}`);
133
+ }
134
+ }
135
+ console.log(color(`
136
+ Shared documentation`, C.bold, C.cyan));
137
+ const docsDir = getDocsDir();
138
+ const allDocDests = /* @__PURE__ */ new Set();
139
+ for (const name of names) {
140
+ for (const doc of sharedDocsForAgent(name)) {
141
+ if (allDocDests.has(doc.dest)) continue;
142
+ allDocDests.add(doc.dest);
143
+ const srcPath = path.join(docsDir, doc.src);
144
+ if (!await fileExists(srcPath)) {
145
+ console.log(color(` skip ${doc.dest} (not found)`, C.yellow));
146
+ continue;
147
+ }
148
+ const result = await copyFile(srcPath, doc.dest, cwd);
149
+ const icon = result === "created" ? color("+", C.green) : result === "updated" ? color("~", C.yellow) : color("=", C.gray);
150
+ const label = result === "exists" ? "unchanged" : result;
151
+ console.log(` ${icon} ${doc.dest} ${color(`(${label})`, C.gray)}`);
152
+ }
153
+ }
154
+ console.log(color("\n Done. Your AI agent can now generate API Spector tests.\n", C.green));
155
+ }
156
+ function listAgents() {
157
+ console.log(color("\n Available agents:\n", C.bold));
158
+ for (const [key, agent] of Object.entries(AGENTS)) {
159
+ console.log(` ${color(key.padEnd(12), C.cyan)} ${agent.description}`);
160
+ }
161
+ console.log(` ${color("all".padEnd(12), C.cyan)} Initialize all agents at once`);
162
+ console.log(color("\n Usage: api-spector agents init <name>\n", C.gray));
163
+ }
164
+ function printHelp() {
165
+ console.log(`
166
+ ${color("api-spector agents", C.bold)} — manage AI agent configurations
167
+
168
+ ${color("Commands:", C.bold)}
169
+ agents init <name> Scaffold agent instruction files in the current directory
170
+ agents list Show available agents
171
+ agents --help Show this message
172
+
173
+ ${color("Examples:", C.bold)}
174
+ api-spector agents init claude Set up Claude Code skills
175
+ api-spector agents init copilot Set up GitHub Copilot instructions
176
+ api-spector agents init all Set up all agents at once
177
+
178
+ ${color("What this does:", C.gray)}
179
+ Copies AI instruction files into your project so your LLM coding tool
180
+ understands the API Spector scripting API and can generate functional
181
+ and security test plans.
182
+ `);
183
+ }
184
+ async function main() {
185
+ const args = process.argv.slice(2);
186
+ const subCmd = args[0];
187
+ if (!subCmd || subCmd === "--help" || subCmd === "-h") {
188
+ printHelp();
189
+ process.exit(0);
190
+ }
191
+ if (subCmd === "list") {
192
+ listAgents();
193
+ process.exit(0);
194
+ }
195
+ if (subCmd === "init") {
196
+ const agentName = args[1]?.toLowerCase();
197
+ if (!agentName) {
198
+ console.error(color(" Missing agent name. Use: api-spector agents init <name>", C.red));
199
+ console.error(` Available: ${Object.keys(AGENTS).join(", ")}, all`);
200
+ process.exit(1);
201
+ }
202
+ await initAgent(agentName, process.cwd());
203
+ process.exit(0);
204
+ }
205
+ console.error(color(` Unknown sub-command: "${subCmd}"`, C.red));
206
+ printHelp();
207
+ process.exit(1);
208
+ }
209
+ main().catch((err) => {
210
+ console.error(color(` Error: ${err.message}`, C.red));
211
+ process.exit(2);
212
+ });
@@ -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);
189
- const enabled = params.filter((p) => p.enabled && p.key);
190
- if (!enabled.length) return url;
190
+ const templateTokens = /* @__PURE__ */ new Set();
191
+ baseUrl.replace(/\{\{([^}]+)\}\}/g, (_m, name) => {
192
+ templateTokens.add(String(name).trim());
193
+ return "";
194
+ });
195
+ const enabled = (params ?? []).filter((p) => p.enabled && p.key);
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,11 +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
- const proxyConnect = { rejectUnauthorized: false, ...connectOpts };
767
946
  return new undici.ProxyAgent({
768
947
  uri: proxyUri,
769
- connect: proxyConnect
948
+ requestTls: proxyConnect,
949
+ proxyTls: proxyConnect
770
950
  });
771
951
  }
772
952
  if (hasTls) {
@@ -785,6 +965,10 @@ function registerRequestHandler(ipc) {
785
965
  tls,
786
966
  piiMaskPatterns = []
787
967
  } = payload;
968
+ if (!req.headers) req.headers = [];
969
+ if (!req.params) req.params = [];
970
+ if (!req.body) req.body = { mode: "none" };
971
+ if (!req.auth) req.auth = { type: "none" };
788
972
  const start = Date.now();
789
973
  const liveGlobals = getGlobals();
790
974
  const mergedGlobals = { ...payloadGlobals, ...liveGlobals };
@@ -809,7 +993,7 @@ function registerRequestHandler(ipc) {
809
993
  let updatedEnvVars = { ...envVars };
810
994
  let updatedGlobals = { ...mergedGlobals };
811
995
  if (req.preRequestScript?.trim()) {
812
- const result = await runScript(req.preRequestScript, {
996
+ const result = await runScript(interpolate(req.preRequestScript, vars), {
813
997
  envVars: { ...envVars },
814
998
  collectionVars: { ...collectionVars },
815
999
  globals: { ...mergedGlobals },
@@ -969,6 +1153,14 @@ function registerRequestHandler(ipc) {
969
1153
  durationMs
970
1154
  };
971
1155
  } catch (err) {
1156
+ const diagnostic = formatRequestError(err, {
1157
+ requestId: req.id,
1158
+ method: req.method,
1159
+ resolvedUrl,
1160
+ proxy,
1161
+ tls
1162
+ });
1163
+ console.error(diagnostic);
972
1164
  response = {
973
1165
  status: 0,
974
1166
  statusText: "Error",
@@ -976,14 +1168,15 @@ function registerRequestHandler(ipc) {
976
1168
  body: "",
977
1169
  bodySize: 0,
978
1170
  durationMs: Date.now() - start,
979
- error: err instanceof Error ? err.cause instanceof Error ? `${err.message}: ${err.cause.message}` : err.message : String(err)
1171
+ error: diagnostic
980
1172
  };
981
1173
  }
1174
+ const schemaTestResults = !response.error ? buildSchemaTestResults(req.schema, response.body) : [];
982
1175
  let postTestResults = [];
983
1176
  let postConsole = [];
984
1177
  let postError;
985
1178
  if (req.postRequestScript?.trim() && !response.error) {
986
- const result = await runScript(req.postRequestScript, {
1179
+ const result = await runScript(interpolate(req.postRequestScript, vars), {
987
1180
  envVars: { ...updatedEnvVars },
988
1181
  collectionVars: { ...updatedCollectionVars },
989
1182
  globals: { ...updatedGlobals },
@@ -1000,8 +1193,16 @@ function registerRequestHandler(ipc) {
1000
1193
  patchGlobals(result.updatedGlobals);
1001
1194
  await persistGlobals();
1002
1195
  }
1196
+ const combinedTestResults = [...schemaTestResults, ...postTestResults];
1197
+ if (!response.error && response.status >= 400 && combinedTestResults.length === 0) {
1198
+ combinedTestResults.push({
1199
+ name: `HTTP status ${response.status} ${response.statusText}`.trim(),
1200
+ passed: false,
1201
+ error: `Request returned ${response.status} — no assertion was defined to verify the status code.`
1202
+ });
1203
+ }
1003
1204
  const scriptResult = {
1004
- testResults: postTestResults,
1205
+ testResults: combinedTestResults,
1005
1206
  consoleOutput: [...decryptionWarnings, ...preScriptMeta.consoleOutput, ...postConsole],
1006
1207
  updatedEnvVars,
1007
1208
  updatedCollectionVars,
@@ -1027,12 +1228,78 @@ function registerRequestHandler(ipc) {
1027
1228
  };
1028
1229
  });
1029
1230
  }
1231
+ function collectTagged(folder, requests, collectionVars, filterTags, parentPath = [], isRoot = true) {
1232
+ const results = [];
1233
+ const scopePath = isRoot ? parentPath : [...parentPath, folder.name];
1234
+ for (const reqId of folder.requestIds) {
1235
+ const req = requests[reqId];
1236
+ if (!req || req.hookType || req.disabled) continue;
1237
+ const tags = req.meta?.tags ?? [];
1238
+ if (filterTags.length > 0 && !filterTags.some((t) => tags.includes(t))) continue;
1239
+ results.push({ request: req, collectionVars, scopePath });
1240
+ }
1241
+ for (const sub of folder.folders) {
1242
+ const folderTags = sub.tags ?? [];
1243
+ const effectiveTags = filterTags.length === 0 ? filterTags : folderTags.some((t) => filterTags.includes(t)) ? [] : filterTags;
1244
+ results.push(...collectTagged(sub, requests, collectionVars, effectiveTags, scopePath, false));
1245
+ }
1246
+ return results;
1247
+ }
1248
+ function folderPathTo(root, requestId) {
1249
+ if (root.requestIds.includes(requestId)) return [root];
1250
+ for (const sub of root.folders) {
1251
+ const path2 = folderPathTo(sub, requestId);
1252
+ if (path2.length > 0) return [root, ...path2];
1253
+ }
1254
+ return [];
1255
+ }
1256
+ function getAllApplicableHooks(folderId, collection) {
1257
+ function chainToFolder(root, targetId) {
1258
+ if (root.id === targetId) return [root];
1259
+ for (const sub of root.folders) {
1260
+ const chain2 = chainToFolder(sub, targetId);
1261
+ if (chain2.length) return [root, ...chain2];
1262
+ }
1263
+ return [];
1264
+ }
1265
+ const chain = chainToFolder(collection.rootFolder, folderId);
1266
+ const beforeAll = [];
1267
+ const before = [];
1268
+ const after = [];
1269
+ const afterAll = [];
1270
+ for (const folder of chain) {
1271
+ const reqs = folder.requestIds.map((id) => collection.requests[id]).filter((r) => r && !r.disabled);
1272
+ beforeAll.push(...reqs.filter((r) => r.hookType === "beforeAll"));
1273
+ before.push(...reqs.filter((r) => r.hookType === "before"));
1274
+ }
1275
+ for (const folder of [...chain].reverse()) {
1276
+ const reqs = folder.requestIds.map((id) => collection.requests[id]).filter((r) => r && !r.disabled);
1277
+ after.push(...reqs.filter((r) => r.hookType === "after"));
1278
+ afterAll.push(...reqs.filter((r) => r.hookType === "afterAll"));
1279
+ }
1280
+ return { beforeAll, before, after, afterAll };
1281
+ }
1282
+ function resolveInheritedAuthAndHeaders(requestId, collection) {
1283
+ let inheritedAuth = collection.auth && collection.auth.type !== "none" ? collection.auth : null;
1284
+ let inheritedHeaders = collection.headers?.filter((h) => h.enabled && h.key) ?? [];
1285
+ const path2 = folderPathTo(collection.rootFolder, requestId);
1286
+ for (const folder of path2) {
1287
+ if (folder.auth && folder.auth.type !== "none") inheritedAuth = folder.auth;
1288
+ if (folder.headers?.length) {
1289
+ inheritedHeaders = [...inheritedHeaders, ...folder.headers.filter((h) => h.enabled && h.key)];
1290
+ }
1291
+ }
1292
+ return { auth: inheritedAuth, headers: inheritedHeaders };
1293
+ }
1030
1294
  exports.buildAuthHeaders = buildAuthHeaders;
1031
1295
  exports.buildDispatcher = buildDispatcher;
1032
1296
  exports.buildDynamicVars = buildDynamicVars;
1033
1297
  exports.buildEnvVars = buildEnvVars;
1298
+ exports.buildSchemaTestResults = buildSchemaTestResults;
1034
1299
  exports.buildUrl = buildUrl;
1300
+ exports.collectTagged = collectTagged;
1035
1301
  exports.fetchOAuth2Token = fetchOAuth2Token;
1302
+ exports.getAllApplicableHooks = getAllApplicableHooks;
1036
1303
  exports.getGlobals = getGlobals;
1037
1304
  exports.getSecret = getSecret;
1038
1305
  exports.initSecretStore = initSecretStore;
@@ -1047,5 +1314,6 @@ exports.performNtlmRequest = performNtlmRequest;
1047
1314
  exports.persistGlobals = persistGlobals;
1048
1315
  exports.registerRequestHandler = registerRequestHandler;
1049
1316
  exports.registerSecretHandlers = registerSecretHandlers;
1317
+ exports.resolveInheritedAuthAndHeaders = resolveInheritedAuthAndHeaders;
1050
1318
  exports.runScript = runScript;
1051
1319
  exports.setGlobals = setGlobals;