@testsmith/api-spector 0.2.3 → 0.2.5

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.
@@ -4,7 +4,7 @@ const promises = require("fs/promises");
4
4
  const path = require("path");
5
5
  const undici = require("undici");
6
6
  const authBuilder = require("./chunks/auth-builder-B7-LgcGr.js");
7
- const requestCollection = require("./chunks/request-collection-DMVlm0PA.js");
7
+ const requestCollection = require("./chunks/request-collection-CElFJzre.js");
8
8
  require("crypto");
9
9
  require("dayjs");
10
10
  require("vm");
@@ -12,6 +12,7 @@ require("tv4");
12
12
  require("jsonpath-plus");
13
13
  require("@xmldom/xmldom");
14
14
  require("ajv");
15
+ require("./chunks/ipc-validate-CscN4HfG.js");
15
16
  function buildJsonReport(results, summary, meta = {}) {
16
17
  return JSON.stringify({
17
18
  timestamp: meta.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
@@ -267,7 +268,7 @@ function buildHtmlReport(results, summary, meta = {}) {
267
268
  const html = document.documentElement
268
269
  const isLight = html.classList.toggle('light')
269
270
  document.getElementById('themeBtn').textContent = isLight ? '🌙' : '☀️'
270
- try { localStorage.setItem('theme', isLight ? 'light' : 'dark') } catch {}
271
+ try { localStorage.setItem('theme', isLight ? 'light' : 'dark') } catch {} /* private mode / quota — non-fatal */
271
272
  }
272
273
  // Restore saved preference or respect OS preference
273
274
  (function() {
@@ -277,7 +278,7 @@ function buildHtmlReport(results, summary, meta = {}) {
277
278
  document.documentElement.classList.add('light')
278
279
  document.getElementById('themeBtn').textContent = '🌙'
279
280
  }
280
- } catch {}
281
+ } catch {} /* private mode / quota — non-fatal */
281
282
  })()
282
283
  <\/script>
283
284
  </body>
@@ -402,7 +403,7 @@ async function loadEnvironments(workspace, dir) {
402
403
  }
403
404
  return envs;
404
405
  }
405
- async function executeRequest(req, collectionVars, envVars, globals, localVars, verbose, tls) {
406
+ async function executeRequest(req, collectionVars, envVars, globals, localVars, verbose, tls, piiMaskPatterns = []) {
406
407
  if (!req.headers) req.headers = [];
407
408
  if (!req.params) req.params = [];
408
409
  if (!req.body) req.body = { mode: "none" };
@@ -425,7 +426,8 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
425
426
  envVars: { ...envVars },
426
427
  collectionVars: { ...collectionVars },
427
428
  globals: { ...globals },
428
- localVars: { ...localVars }
429
+ localVars: { ...localVars },
430
+ piiMaskPatterns
429
431
  });
430
432
  preScriptError = r.error;
431
433
  localVars = r.updatedLocalVars;
@@ -473,6 +475,12 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
473
475
  if (gql.operationName?.trim()) gqlBody.operationName = gql.operationName.trim();
474
476
  body = JSON.stringify(gqlBody);
475
477
  if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
478
+ } else if (req.body.mode === "soap" && req.body.soap) {
479
+ body = authBuilder.interpolate(req.body.soap.envelope, vars);
480
+ if (!headers.has("content-type")) headers.set("Content-Type", "text/xml; charset=utf-8");
481
+ if (req.body.soap.soapAction && !headers.has("soapaction")) {
482
+ headers.set("SOAPAction", req.body.soap.soapAction);
483
+ }
476
484
  }
477
485
  const dispatcher = await requestCollection.buildDispatcher(void 0, tls);
478
486
  const fetchResp = await undici.fetch(resolvedUrl, {
@@ -495,7 +503,8 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
495
503
  bodySize: Buffer.byteLength(responseBody, "utf8"),
496
504
  durationMs
497
505
  };
498
- let testResults = [];
506
+ const protocolFaultTests = requestCollection.buildProtocolFaultTests(req.body.mode, responseBody);
507
+ let testResults = [...protocolFaultTests];
499
508
  let consoleOutput = [];
500
509
  let postScriptError;
501
510
  if (req.postRequestScript?.trim()) {
@@ -504,9 +513,10 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
504
513
  collectionVars: updatedCollectionVars,
505
514
  globals: updatedGlobals,
506
515
  localVars,
507
- response
516
+ response,
517
+ piiMaskPatterns
508
518
  });
509
- testResults = r.testResults;
519
+ testResults = [...protocolFaultTests, ...r.testResults];
510
520
  consoleOutput = r.consoleOutput;
511
521
  postScriptError = r.error;
512
522
  updatedEnvVars = r.updatedEnvVars;
@@ -520,7 +530,7 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
520
530
  const allPassed = testResults.every((t) => t.passed);
521
531
  const httpFailed = fetchResp.status >= 400;
522
532
  const hasTests = testResults.length > 0;
523
- const status = postScriptError ? "error" : hasTests ? allPassed ? "passed" : "failed" : httpFailed ? "failed" : "skipped";
533
+ const status = postScriptError ? "error" : hasTests ? allPassed ? "passed" : "failed" : httpFailed ? "failed" : "passed";
524
534
  if (httpFailed && testResults.length === 0) {
525
535
  testResults = [
526
536
  ...testResults,
@@ -574,7 +584,8 @@ function printResult(r, verbose) {
574
584
  const http = r.httpStatus ? color(` ${r.httpStatus}`, r.httpStatus < 400 ? C.green : C.red) : "";
575
585
  const dur = r.durationMs !== void 0 ? color(` ${r.durationMs}ms`, C.gray) : "";
576
586
  const method = color(r.method.padEnd(7), C.cyan);
577
- console.log(` ${icon} ${method} ${r.name}${http}${dur}`);
587
+ const hookTag = r.isHook && r.hookType ? color(` [${r.hookType.toUpperCase()}]`, C.yellow) : "";
588
+ console.log(` ${icon} ${method} ${r.name}${hookTag}${http}${dur}`);
578
589
  if (verbose) console.log(color(` ${r.resolvedUrl}`, C.gray));
579
590
  if (r.testResults?.length) {
580
591
  for (const t of r.testResults) {
@@ -621,8 +632,9 @@ async function main() {
621
632
  if (envName && !env) {
622
633
  console.warn(color(`Warning: environment "${envName}" not found. Running without environment.`, C.yellow));
623
634
  }
635
+ const version = `v${"0.2.5"}`;
624
636
  console.log("");
625
- console.log(color(" API Test Runner", C.bold, C.white));
637
+ console.log(color(" API Test Runner" + (version ? ` ${version}` : ""), C.bold, C.white));
626
638
  console.log(color(` Workspace: ${wsPath}`, C.gray));
627
639
  console.log(color(` Environment: ${env?.name ?? "(none)"}`, C.gray));
628
640
  if (filterTags.length) console.log(color(` Tags: ${filterTags.join(", ")}`, C.gray));
@@ -634,16 +646,21 @@ async function main() {
634
646
  for (const secret of secretValuesToMask) out = out.split(secret).join("***");
635
647
  return out;
636
648
  }
649
+ const DEFAULT_PII_PATTERNS = ["authorization", "password", "token", "secret", "api-key", "x-api-key"];
650
+ const piiPatterns = workspace.settings?.piiMaskPatterns ?? DEFAULT_PII_PATTERNS;
637
651
  function maskResult(r) {
638
652
  return {
639
653
  ...r,
640
654
  sentRequest: r.sentRequest ? {
641
- headers: Object.fromEntries(Object.entries(r.sentRequest.headers).map(([k, v]) => [k, redact(v)])),
642
- body: r.sentRequest.body != null ? redact(r.sentRequest.body) : void 0
655
+ headers: Object.fromEntries(
656
+ Object.entries(requestCollection.maskHeaders(r.sentRequest.headers, piiPatterns)).map(([k, v]) => [k, redact(v)])
657
+ ),
658
+ body: r.sentRequest.body != null ? redact(requestCollection.maskPii(r.sentRequest.body, piiPatterns)) : void 0
643
659
  } : void 0,
644
660
  receivedResponse: r.receivedResponse ? {
645
661
  ...r.receivedResponse,
646
- body: redact(r.receivedResponse.body)
662
+ headers: requestCollection.maskHeaders(r.receivedResponse.headers, piiPatterns),
663
+ body: redact(requestCollection.maskPii(r.receivedResponse.body, piiPatterns))
647
664
  } : void 0
648
665
  };
649
666
  }
@@ -654,7 +671,7 @@ async function main() {
654
671
  let firstColName;
655
672
  for (const col of collections) {
656
673
  if (colName && col.name.toLowerCase() !== colName.toLowerCase()) continue;
657
- const items = requestCollection.collectTagged(col.rootFolder, col.requests, col.collectionVariables ?? {}, filterTags);
674
+ const items = requestCollection.buildRunPlan(col, null, filterTags);
658
675
  if (items.length === 0) continue;
659
676
  if (!firstColName) firstColName = col.name;
660
677
  let runEnvVars = await authBuilder.buildEnvVars(env);
@@ -677,21 +694,74 @@ async function main() {
677
694
  }
678
695
  let bailed = false;
679
696
  let lastPrintedScope = null;
697
+ const failedScopes = /* @__PURE__ */ new Set();
698
+ const skipRequests = /* @__PURE__ */ new Set();
680
699
  for (const item of items) {
681
- const { result, updatedEnvVars, updatedCollectionVars, updatedGlobals, updatedLocalVars } = await executeRequest(
682
- item.request,
683
- { ...item.collectionVars, ...runCollectionVars },
684
- runEnvVars,
685
- runGlobals,
686
- { ...runLocalVars },
687
- verbose,
688
- effectiveTls
689
- );
690
- runEnvVars = updatedEnvVars;
691
- runCollectionVars = updatedCollectionVars;
692
- runGlobals = updatedGlobals;
693
- runLocalVars = updatedLocalVars;
694
- result.scopePath = item.scopePath;
700
+ const { isHook, hookType, scopeId, scopeAncestors, mainRequestId } = item;
701
+ let skipReason;
702
+ if (isHook) {
703
+ if (hookType === "beforeAll") {
704
+ if ((scopeAncestors ?? []).some((id) => failedScopes.has(id))) {
705
+ skipReason = "Skipped — outer scope hook failed";
706
+ }
707
+ } else if (hookType === "before") {
708
+ const allScopes = [...scopeAncestors ?? [], scopeId].filter(Boolean);
709
+ if (allScopes.some((id) => failedScopes.has(id))) {
710
+ skipReason = "Skipped — scope hook failed";
711
+ } else if (mainRequestId && skipRequests.has(mainRequestId)) {
712
+ skipReason = "Skipped — before hook failed";
713
+ }
714
+ }
715
+ } else {
716
+ const allScopes = [...scopeAncestors ?? [], scopeId].filter(Boolean);
717
+ if (allScopes.some((id) => failedScopes.has(id))) {
718
+ skipReason = "Skipped — beforeAll hook failed";
719
+ } else if (skipRequests.has(item.request.id)) {
720
+ skipReason = "Skipped — before hook failed";
721
+ }
722
+ }
723
+ let result;
724
+ if (skipReason) {
725
+ result = {
726
+ requestId: item.request.id,
727
+ name: item.request.name,
728
+ method: item.request.method,
729
+ resolvedUrl: item.request.url,
730
+ status: "failed",
731
+ error: skipReason,
732
+ isHook,
733
+ hookType,
734
+ scopeId,
735
+ scopePath: item.scopePath
736
+ };
737
+ } else {
738
+ const out = await executeRequest(
739
+ item.request,
740
+ { ...item.collectionVars, ...runCollectionVars },
741
+ runEnvVars,
742
+ runGlobals,
743
+ { ...runLocalVars },
744
+ verbose,
745
+ effectiveTls,
746
+ piiPatterns
747
+ );
748
+ result = out.result;
749
+ runEnvVars = out.updatedEnvVars;
750
+ runCollectionVars = out.updatedCollectionVars;
751
+ runGlobals = out.updatedGlobals;
752
+ runLocalVars = out.updatedLocalVars;
753
+ result.isHook = isHook;
754
+ result.hookType = hookType;
755
+ result.scopeId = scopeId;
756
+ result.scopePath = item.scopePath;
757
+ if (result.status === "failed" || result.status === "error") {
758
+ if (isHook && hookType === "beforeAll" && scopeId) {
759
+ failedScopes.add(scopeId);
760
+ } else if (isHook && hookType === "before" && mainRequestId) {
761
+ skipRequests.add(mainRequestId);
762
+ }
763
+ }
764
+ }
695
765
  const scopeKey = (item.scopePath ?? []).join(" / ");
696
766
  if (scopeKey !== lastPrintedScope) {
697
767
  if (scopeKey) console.log(color(` ${scopeKey}`, C.gray, C.bold));
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const promises = require("fs/promises");
4
+ const path = require("path");
5
+ const https = require("https");
6
+ const http = require("http");
7
+ const soapHandler = require("./chunks/soap-handler-Cpj-JwyA.js");
8
+ const _import = require("./chunks/import-C9qdkBCH.js");
9
+ require("@xmldom/xmldom");
10
+ require("uuid");
11
+ function parseArgs(argv) {
12
+ const args = {};
13
+ for (let i = 0; i < argv.length; i++) {
14
+ const arg = argv[i];
15
+ if (arg.startsWith("--")) {
16
+ const key = arg.slice(2);
17
+ const next = argv[i + 1];
18
+ if (!next || next.startsWith("--")) {
19
+ args[key] = true;
20
+ } else {
21
+ args[key] = next;
22
+ i++;
23
+ }
24
+ }
25
+ }
26
+ return args;
27
+ }
28
+ function fetchUrl(url) {
29
+ return new Promise((resolveP, rejectP) => {
30
+ const lib = url.startsWith("https") ? https : http;
31
+ const req = lib.get(url, (res) => {
32
+ const chunks = [];
33
+ res.on("data", (c) => chunks.push(c));
34
+ res.on("end", () => resolveP(Buffer.concat(chunks).toString("utf8")));
35
+ res.on("error", rejectP);
36
+ });
37
+ req.on("error", rejectP);
38
+ req.setTimeout(15e3, () => {
39
+ req.destroy();
40
+ rejectP(new Error("WSDL fetch timed out"));
41
+ });
42
+ });
43
+ }
44
+ async function resolveWorkspacePath(wsPath) {
45
+ const s = await promises.stat(wsPath);
46
+ if (!s.isDirectory()) return wsPath;
47
+ const entries = await promises.readdir(wsPath);
48
+ const spector = entries.find((e) => e.endsWith(".spector"));
49
+ if (!spector) throw new Error(`No .spector workspace file found in directory: ${wsPath}`);
50
+ return path.join(wsPath, spector);
51
+ }
52
+ async function loadWorkspace(wsPath) {
53
+ const resolved = await resolveWorkspacePath(wsPath);
54
+ const raw = await promises.readFile(resolved, "utf8");
55
+ return { workspace: JSON.parse(raw), dir: path.dirname(path.resolve(resolved)), file: resolved };
56
+ }
57
+ async function ensureDir(dir) {
58
+ await promises.mkdir(dir, { recursive: true });
59
+ }
60
+ async function cmdDescribe(args) {
61
+ const url = typeof args["url"] === "string" ? args["url"] : void 0;
62
+ if (!url) {
63
+ console.error(" [error] --url <wsdlUrl> is required");
64
+ process.exit(2);
65
+ }
66
+ const wsdlText = await fetchUrl(url);
67
+ const parsed = soapHandler.parseWsdl(wsdlText);
68
+ console.log("");
69
+ console.log(` Target namespace: ${parsed.targetNamespace || "(none)"}`);
70
+ if (parsed.endpoints.length) {
71
+ console.log(" Endpoints:");
72
+ for (const e of parsed.endpoints) {
73
+ console.log(` [${e.soapVersion}] ${e.binding} → ${e.address}`);
74
+ }
75
+ }
76
+ console.log("");
77
+ if (parsed.operations.length === 0) {
78
+ console.log(" No operations found.");
79
+ return;
80
+ }
81
+ console.log(" Operation Ver SOAPAction");
82
+ console.log(" ───────────────────────────────────── ───── ──────────────────────────────");
83
+ for (const op of parsed.operations) {
84
+ const name = op.name.slice(0, 37).padEnd(37);
85
+ const ver = op.soapVersion.padEnd(5);
86
+ const sa = (op.soapAction ?? "—").slice(0, 30);
87
+ console.log(` ${name} ${ver} ${sa}`);
88
+ }
89
+ console.log("");
90
+ }
91
+ async function loadExistingMockPorts(workspace, dir) {
92
+ const ports = [];
93
+ for (const relPath of workspace.mocks ?? []) {
94
+ try {
95
+ const raw = await promises.readFile(path.join(dir, relPath), "utf8");
96
+ const m = JSON.parse(raw);
97
+ if (typeof m.port === "number") ports.push(m.port);
98
+ } catch {
99
+ }
100
+ }
101
+ return ports;
102
+ }
103
+ async function cmdImportCollection(args) {
104
+ const url = typeof args["url"] === "string" ? args["url"] : void 0;
105
+ const wsArg = typeof args["workspace"] === "string" ? args["workspace"] : void 0;
106
+ if (!url) {
107
+ console.error(" [error] --url <wsdlUrl> is required");
108
+ process.exit(2);
109
+ }
110
+ if (!wsArg) {
111
+ console.error(" [error] --workspace <path> is required");
112
+ process.exit(2);
113
+ }
114
+ const wsdlText = await fetchUrl(url);
115
+ const { workspace, dir, file } = await loadWorkspace(wsArg);
116
+ const name = typeof args["name"] === "string" ? args["name"] : void 0;
117
+ const { collection } = _import.importWsdl(wsdlText, { name });
118
+ const relPath = _import.defaultCollectionRelPath(workspace, collection);
119
+ const fullPath = path.resolve(dir, relPath);
120
+ await ensureDir(path.dirname(fullPath));
121
+ await promises.writeFile(fullPath, JSON.stringify(collection, null, 2), "utf8");
122
+ workspace.collections.push(relPath);
123
+ await promises.writeFile(file, JSON.stringify(workspace, null, 2), "utf8");
124
+ console.log(` ✓ Wrote ${relPath} (${Object.keys(collection.requests).length} requests)`);
125
+ }
126
+ async function cmdImportMock(args) {
127
+ const url = typeof args["url"] === "string" ? args["url"] : void 0;
128
+ const wsArg = typeof args["workspace"] === "string" ? args["workspace"] : void 0;
129
+ if (!url) {
130
+ console.error(" [error] --url <wsdlUrl> is required");
131
+ process.exit(2);
132
+ }
133
+ if (!wsArg) {
134
+ console.error(" [error] --workspace <path> is required");
135
+ process.exit(2);
136
+ }
137
+ const wsdlText = await fetchUrl(url);
138
+ const { workspace, dir, file } = await loadWorkspace(wsArg);
139
+ const existingPorts = await loadExistingMockPorts(workspace, dir);
140
+ const name = typeof args["name"] === "string" ? args["name"] : void 0;
141
+ const { mock } = _import.importWsdl(wsdlText, { name, existingMockPorts: existingPorts });
142
+ const relPath = _import.defaultMockRelPath(mock);
143
+ const fullPath = path.resolve(dir, relPath);
144
+ await ensureDir(path.dirname(fullPath));
145
+ await promises.writeFile(fullPath, JSON.stringify(mock, null, 2), "utf8");
146
+ if (!workspace.mocks) workspace.mocks = [];
147
+ workspace.mocks.push(relPath);
148
+ await promises.writeFile(file, JSON.stringify(workspace, null, 2), "utf8");
149
+ console.log(` ✓ Wrote ${relPath} on port ${mock.port} (${mock.routes.length} dispatch route${mock.routes.length === 1 ? "" : "s"})`);
150
+ if (args["start"] === true) {
151
+ console.log(" [note] --start is not supported in CLI mode; launch the mock from the app.");
152
+ }
153
+ }
154
+ async function main() {
155
+ const [, , sub, ...rest] = process.argv;
156
+ const args = parseArgs(rest);
157
+ if (sub === "describe") return cmdDescribe(args);
158
+ if (sub === "import-collection") return cmdImportCollection(args);
159
+ if (sub === "import-mock") return cmdImportMock(args);
160
+ if (args["help"] || !sub) {
161
+ console.log(`
162
+ api-spector wsdl describe --url <wsdlUrl>
163
+ api-spector wsdl import-collection --workspace <path> --url <wsdlUrl> [--name <label>]
164
+ api-spector wsdl import-mock --workspace <path> --url <wsdlUrl> [--name <label>]
165
+ `);
166
+ return;
167
+ }
168
+ console.error(` [error] Unknown subcommand "${sub}"`);
169
+ process.exit(2);
170
+ }
171
+ main().catch((e) => {
172
+ console.error(` [error] ${e instanceof Error ? e.message : String(e)}`);
173
+ process.exit(1);
174
+ });
@@ -11,6 +11,10 @@ const api = {
11
11
  saveCollection: (relPath, col) => electron.ipcRenderer.invoke("file:saveCollection", relPath, col),
12
12
  loadEnvironment: (relPath) => electron.ipcRenderer.invoke("file:loadEnvironment", relPath),
13
13
  saveEnvironment: (relPath, env) => electron.ipcRenderer.invoke("file:saveEnvironment", relPath, env),
14
+ /** Idempotent unlink of a workspace-relative file. Used by collection /
15
+ * environment / mock delete flows so the file is removed from disk, not
16
+ * just from the workspace manifest. */
17
+ deleteWorkspaceFile: (relPath) => electron.ipcRenderer.invoke("file:deleteWorkspaceFile", relPath),
14
18
  // ─── HTTP execution ────────────────────────────────────────────────────────
15
19
  sendRequest: (payload) => electron.ipcRenderer.invoke("request:send", payload),
16
20
  // ─── Secrets (encrypted, master-key-based) ───────────────────────────────
@@ -78,6 +82,9 @@ const api = {
78
82
  },
79
83
  // ─── SOAP / WSDL ──────────────────────────────────────────────────────────
80
84
  wsdlFetch: (url, extraHeaders) => electron.ipcRenderer.invoke("wsdl:fetch", url, extraHeaders ?? {}),
85
+ /** Build a Collection + MockServer from a WSDL. Renderer registers the
86
+ * returned objects via the usual loadCollection/loadMock + save flows. */
87
+ wsdlImport: (opts) => electron.ipcRenderer.invoke("wsdl:import", opts),
81
88
  // ─── Docs generation ──────────────────────────────────────────────────────
82
89
  generateDocs: (payload) => electron.ipcRenderer.invoke("docs:generate", payload),
83
90
  // ─── Contract testing ─────────────────────────────────────────────────────
@@ -102,6 +109,7 @@ const api = {
102
109
  gitLog: (limit) => electron.ipcRenderer.invoke("git:log", limit),
103
110
  gitBranches: () => electron.ipcRenderer.invoke("git:branches"),
104
111
  gitCheckout: (branch, create) => electron.ipcRenderer.invoke("git:checkout", branch, create),
112
+ gitDeleteBranch: (name, force = false) => electron.ipcRenderer.invoke("git:deleteBranch", name, force),
105
113
  gitPull: () => electron.ipcRenderer.invoke("git:pull"),
106
114
  gitPush: (setUpstream) => electron.ipcRenderer.invoke("git:push", setUpstream),
107
115
  gitRemotes: () => electron.ipcRenderer.invoke("git:remotes"),