@testsmith/api-spector 0.2.4 → 0.2.6

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
@@ -63,7 +63,48 @@ if (!command) {
63
63
 
64
64
  // ui: spawn electron with the app dir
65
65
  if (command.runner === 'electron') {
66
- const electron = require('electron')
66
+ // `require('electron')` throws if electron's postinstall didn't download
67
+ // the platform binary (common behind corporate proxies on Windows: the
68
+ // npm install completes but the GitHub Releases download is blocked).
69
+ // The raw stack trace is intimidating; turn it into actionable steps.
70
+ let electron
71
+ try {
72
+ electron = require('electron')
73
+ } catch (err) {
74
+ const msg = err && err.message ? err.message : String(err)
75
+ const looksLikeBinaryMissing = /Electron failed to install correctly|Cannot find module 'electron'/i.test(msg)
76
+ console.error('')
77
+ console.error(' API Spector — failed to launch the UI.')
78
+ console.error('')
79
+ if (looksLikeBinaryMissing) {
80
+ const installDir = path.dirname(__dirname)
81
+ console.error(' Electron is installed, but its platform binary is missing — the')
82
+ console.error(' download during `npm install` did not complete (often a proxy or')
83
+ console.error(' firewall blocking github.com / electronjs.org).')
84
+ console.error('')
85
+ console.error(' Fix options (try in order):')
86
+ console.error('')
87
+ console.error(' 1. Reinstall and force the postinstall script to run:')
88
+ console.error(' npm install -g @testsmith/api-spector --force')
89
+ console.error('')
90
+ console.error(' 2. Behind a proxy? Set npm + electron mirrors and reinstall:')
91
+ console.error(' npm config set proxy http://your-proxy:port')
92
+ console.error(' npm config set https-proxy http://your-proxy:port')
93
+ console.error(' set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/')
94
+ console.error(' npm install -g @testsmith/api-spector --force')
95
+ console.error('')
96
+ console.error(' 3. Re-run electron\'s postinstall manually:')
97
+ console.error(` cd "${path.join(installDir, 'node_modules', 'electron')}"`)
98
+ console.error(' node install.js')
99
+ console.error('')
100
+ console.error(' CLI subcommands (run / mock / record / contract / wsdl) do not')
101
+ console.error(' need the UI binary and should work even while this is broken.')
102
+ } else {
103
+ console.error(` ${msg}`)
104
+ }
105
+ console.error('')
106
+ process.exit(1)
107
+ }
67
108
  const appDir = path.join(__dirname, '..')
68
109
  // Forward the user's cwd so the main process can decide whether to open a
69
110
  // workspace in this folder, or fall through to the welcome screen. Without
@@ -254,13 +254,15 @@ class AssertionError extends Error {
254
254
  }
255
255
  }
256
256
  function buildAt(ctx, testResults, consoleOutput) {
257
- const { envVars, collectionVars, globals: globals2, localVars } = ctx;
257
+ const { envVars, collectionVars, globals: globals2, localVars, piiMaskPatterns = [] } = ctx;
258
+ const isSensitiveKey = (key) => piiMaskPatterns.some((p) => key.toLowerCase().includes(p.toLowerCase()));
258
259
  function makeVarScope(store, scopeName) {
259
260
  return {
260
261
  get: (key) => store[key] ?? null,
261
262
  set: (key, value) => {
262
263
  store[key] = String(value);
263
- consoleOutput.push(`[set] ${scopeName}.${key} = ${JSON.stringify(String(value))}`);
264
+ const display = isSensitiveKey(key) ? '"[REDACTED]"' : JSON.stringify(String(value));
265
+ consoleOutput.push(`[set] ${scopeName}.${key} = ${display}`);
264
266
  },
265
267
  clear: (key) => {
266
268
  delete store[key];
@@ -383,7 +385,8 @@ async function runScript(code, ctx, timeoutMs = 5e3) {
383
385
  collectionVars: collectionCopy,
384
386
  globals: globalsCopy,
385
387
  localVars: localVarsCopy,
386
- response: ctx.response
388
+ response: ctx.response,
389
+ piiMaskPatterns: ctx.piiMaskPatterns
387
390
  };
388
391
  const sp = buildAt(scriptCtx, testResults, consoleOutput);
389
392
  const captureConsole = {
@@ -602,6 +605,40 @@ function buildSchemaTestResults(schemaText, body) {
602
605
  error: err.message ?? "Schema violation"
603
606
  }));
604
607
  }
608
+ function buildProtocolFaultTests(bodyMode, body) {
609
+ if (!body) return [];
610
+ if (bodyMode === "soap") {
611
+ const isFault = /<(?:[\w-]+:)?Fault(?:\s|>)/i.test(body);
612
+ if (isFault) {
613
+ const reason = /<(?:[\w-]+:)?(?:faultstring|Text)[^>]*>([\s\S]*?)<\/(?:[\w-]+:)?(?:faultstring|Text)>/i.exec(body);
614
+ return [{
615
+ name: "[soap] response is not a Fault",
616
+ passed: false,
617
+ error: reason?.[1]?.trim() ?? "SOAP Fault returned"
618
+ }];
619
+ }
620
+ return [{ name: "[soap] response is not a Fault", passed: true }];
621
+ }
622
+ if (bodyMode === "graphql") {
623
+ let parsed;
624
+ try {
625
+ parsed = JSON.parse(body);
626
+ } catch {
627
+ return [];
628
+ }
629
+ const errors = parsed?.errors;
630
+ if (Array.isArray(errors) && errors.length > 0) {
631
+ const first = errors[0];
632
+ return [{
633
+ name: "[graphql] response has no errors",
634
+ passed: false,
635
+ error: first?.message ?? "GraphQL response contained an `errors` array"
636
+ }];
637
+ }
638
+ return [{ name: "[graphql] response has no errors", passed: true }];
639
+ }
640
+ return [];
641
+ }
605
642
  async function buildDispatcher(proxy, tls) {
606
643
  const connectOpts = {};
607
644
  let hasTls = false;
@@ -697,6 +734,7 @@ function registerRequestHandler(ipc) {
697
734
  vars = authBuilder.mergeVars(updatedEnvVars, updatedCollectionVars, updatedGlobals, localVars, dynamicVars);
698
735
  }
699
736
  let response;
737
+ let scriptResponse;
700
738
  let sentRequest = { method: req.method, url: "", headers: {} };
701
739
  const resolvedUrl = authBuilder.buildUrl(req.url, req.params, vars);
702
740
  const secretValues = /* @__PURE__ */ new Set();
@@ -840,6 +878,14 @@ function registerRequestHandler(ipc) {
840
878
  bodySize: Buffer.byteLength(responseBody, "utf8"),
841
879
  durationMs
842
880
  };
881
+ scriptResponse = {
882
+ status: fetchResp.status,
883
+ statusText: fetchResp.statusText,
884
+ headers: rawResponseHeaders,
885
+ body: responseBody,
886
+ bodySize: Buffer.byteLength(responseBody, "utf8"),
887
+ durationMs
888
+ };
843
889
  } catch (err) {
844
890
  const diagnostic = formatRequestError(err, {
845
891
  requestId: req.id,
@@ -858,8 +904,9 @@ function registerRequestHandler(ipc) {
858
904
  durationMs: Date.now() - start,
859
905
  error: diagnostic
860
906
  };
907
+ scriptResponse = response;
861
908
  }
862
- const schemaTestResults = !response.error ? buildSchemaTestResults(req.schema, response.body) : [];
909
+ const schemaTestResults = !response.error ? buildSchemaTestResults(req.schema, scriptResponse.body) : [];
863
910
  let postTestResults = [];
864
911
  let postConsole = [];
865
912
  let postError;
@@ -869,7 +916,9 @@ function registerRequestHandler(ipc) {
869
916
  collectionVars: { ...updatedCollectionVars },
870
917
  globals: { ...updatedGlobals },
871
918
  localVars: { ...localVars },
872
- response
919
+ // Pass the *unmasked* response so the script can extract real values
920
+ // (tokens, ids, …). The displayed `response` keeps the redacted copy.
921
+ response: scriptResponse
873
922
  });
874
923
  postTestResults = result.testResults;
875
924
  postConsole = result.consoleOutput;
@@ -916,22 +965,57 @@ function registerRequestHandler(ipc) {
916
965
  };
917
966
  });
918
967
  }
919
- function collectTagged(folder, requests, collectionVars, filterTags, parentPath = [], isRoot = true) {
920
- const results = [];
921
- const scopePath = isRoot ? parentPath : [...parentPath, folder.name];
922
- for (const reqId of folder.requestIds) {
923
- const req = requests[reqId];
924
- if (!req || req.hookType || req.disabled) continue;
968
+ function makeHook(req, collectionVars, hookType, scopeId, scopeAncestors, scopePath, mainRequestId) {
969
+ return { request: req, collectionVars, isHook: true, hookType, scopeId, scopeAncestors, scopePath, mainRequestId };
970
+ }
971
+ function buildFolderPlan(folder, requests, collectionVars, filterTags, scopeId, ancestorIds, parentPath, wrappers, isRoot) {
972
+ const result = [];
973
+ const scopePath = isRoot ? [] : [...parentPath, folder.name];
974
+ const folderReqs = folder.requestIds.map((id) => requests[id]).filter((r) => r && !r.disabled);
975
+ const beforeAllHooks = folderReqs.filter((r) => r.hookType === "beforeAll");
976
+ const beforeHooks = folderReqs.filter((r) => r.hookType === "before");
977
+ const afterHooks = folderReqs.filter((r) => r.hookType === "after");
978
+ const afterAllHooks = folderReqs.filter((r) => r.hookType === "afterAll");
979
+ const regularReqs = folderReqs.filter((r) => !r.hookType);
980
+ const myWrapper = { scopeId, ancestors: ancestorIds, scopePath, before: beforeHooks, after: afterHooks };
981
+ const allWrappers = [...wrappers, myWrapper];
982
+ for (const req of beforeAllHooks) {
983
+ result.push(makeHook(req, collectionVars, "beforeAll", scopeId, ancestorIds, scopePath));
984
+ }
985
+ for (const req of regularReqs) {
925
986
  const tags = req.meta?.tags ?? [];
926
987
  if (filterTags.length > 0 && !filterTags.some((t) => tags.includes(t))) continue;
927
- results.push({ request: req, collectionVars, scopePath });
988
+ for (const w of allWrappers) {
989
+ for (const hookReq of w.before) {
990
+ result.push(makeHook(hookReq, collectionVars, "before", w.scopeId, w.ancestors, w.scopePath, req.id));
991
+ }
992
+ }
993
+ result.push({ request: req, collectionVars, scopeId, scopeAncestors: ancestorIds, scopePath });
994
+ for (const w of [...allWrappers].reverse()) {
995
+ for (const hookReq of w.after) {
996
+ result.push(makeHook(hookReq, collectionVars, "after", w.scopeId, w.ancestors, w.scopePath, req.id));
997
+ }
998
+ }
928
999
  }
929
1000
  for (const sub of folder.folders) {
930
1001
  const folderTags = sub.tags ?? [];
931
- const effectiveTags = filterTags.length === 0 ? filterTags : folderTags.some((t) => filterTags.includes(t)) ? [] : filterTags;
932
- results.push(...collectTagged(sub, requests, collectionVars, effectiveTags, scopePath, false));
1002
+ const effectiveFilter = filterTags.length === 0 ? filterTags : folderTags.some((t) => filterTags.includes(t)) ? [] : filterTags;
1003
+ result.push(...buildFolderPlan(
1004
+ sub,
1005
+ requests,
1006
+ collectionVars,
1007
+ effectiveFilter,
1008
+ sub.id,
1009
+ [...ancestorIds, scopeId],
1010
+ scopePath,
1011
+ allWrappers,
1012
+ false
1013
+ ));
933
1014
  }
934
- return results;
1015
+ for (const req of afterAllHooks) {
1016
+ result.push(makeHook(req, collectionVars, "afterAll", scopeId, ancestorIds, scopePath));
1017
+ }
1018
+ return result;
935
1019
  }
936
1020
  function folderPathTo(root, requestId) {
937
1021
  if (root.requestIds.includes(requestId)) return [root];
@@ -979,9 +1063,26 @@ function resolveInheritedAuthAndHeaders(requestId, collection) {
979
1063
  }
980
1064
  return { auth: inheritedAuth, headers: inheritedHeaders };
981
1065
  }
1066
+ function buildRunPlan(collection, folderId, filterTags) {
1067
+ const collectionVars = collection.collectionVariables ?? {};
1068
+ {
1069
+ return buildFolderPlan(
1070
+ collection.rootFolder,
1071
+ collection.requests,
1072
+ collectionVars,
1073
+ filterTags,
1074
+ collection.rootFolder.id,
1075
+ [],
1076
+ [],
1077
+ [],
1078
+ true
1079
+ );
1080
+ }
1081
+ }
982
1082
  exports.buildDispatcher = buildDispatcher;
1083
+ exports.buildProtocolFaultTests = buildProtocolFaultTests;
1084
+ exports.buildRunPlan = buildRunPlan;
983
1085
  exports.buildSchemaTestResults = buildSchemaTestResults;
984
- exports.collectTagged = collectTagged;
985
1086
  exports.getAllApplicableHooks = getAllApplicableHooks;
986
1087
  exports.getGlobals = getGlobals;
987
1088
  exports.loadGlobals = loadGlobals;
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 requestCollection = require("./chunks/request-collection-DIsjTggj.js");
28
+ const requestCollection = require("./chunks/request-collection-CElFJzre.js");
29
29
  const authBuilder = require("./chunks/auth-builder-B7-LgcGr.js");
30
30
  const uuid = require("uuid");
31
31
  const jsYaml = require("js-yaml");
@@ -1494,7 +1494,7 @@ function buildTestSuite(collection, environment, nameMap) {
1494
1494
  processFolder(collection.rootFolder);
1495
1495
  return lines.join("\n");
1496
1496
  }
1497
- function renderTree$5(paths) {
1497
+ function renderTree$6(paths) {
1498
1498
  const root = {};
1499
1499
  for (const p of [...paths].sort()) {
1500
1500
  let cur = root;
@@ -1513,8 +1513,8 @@ function renderTree$5(paths) {
1513
1513
  }
1514
1514
  return [".", ...render(root)].join("\n");
1515
1515
  }
1516
- function buildReadme$5(collectionName, filePaths) {
1517
- const tree = renderTree$5(filePaths);
1516
+ function buildReadme$6(collectionName, filePaths) {
1517
+ const tree = renderTree$6(filePaths);
1518
1518
  return `# ${collectionName} — API Tests (Robot Framework)
1519
1519
 
1520
1520
  ## Project structure
@@ -1553,7 +1553,7 @@ function generateRobotFramework(collection, environment) {
1553
1553
  ];
1554
1554
  const allPaths = ["requirements.txt", ...contentFiles.map((f) => f.path)];
1555
1555
  return [
1556
- { path: "README.md", content: buildReadme$5(collection.name, allPaths) },
1556
+ { path: "README.md", content: buildReadme$6(collection.name, allPaths) },
1557
1557
  { path: "requirements.txt", content: "robotframework\nrobotframework-requests\n" },
1558
1558
  ...contentFiles
1559
1559
  ];
@@ -1859,7 +1859,7 @@ function buildPackageJson$3(collectionName) {
1859
1859
  }
1860
1860
  }, null, 2) + "\n";
1861
1861
  }
1862
- function renderTree$4(paths) {
1862
+ function renderTree$5(paths) {
1863
1863
  const root = {};
1864
1864
  for (const p of [...paths].sort()) {
1865
1865
  let cur = root;
@@ -1878,8 +1878,8 @@ function renderTree$4(paths) {
1878
1878
  }
1879
1879
  return [".", ...render(root)].join("\n");
1880
1880
  }
1881
- function buildReadme$4(collectionName, filePaths) {
1882
- const tree = renderTree$4([...filePaths, ".env.local"]);
1881
+ function buildReadme$5(collectionName, filePaths) {
1882
+ const tree = renderTree$5([...filePaths, ".env.local"]);
1883
1883
  return `# ${collectionName} — API Tests (Playwright TypeScript)
1884
1884
 
1885
1885
  ## Project structure
@@ -1914,7 +1914,7 @@ function generatePlaywright(collection, environment) {
1914
1914
  files.unshift(
1915
1915
  { path: "package.json", content: buildPackageJson$3(collection.name) },
1916
1916
  { path: "playwright.config.ts", content: buildPlaywrightConfig$1(environment) },
1917
- { path: "README.md", content: buildReadme$4(collection.name, scaffoldPaths) }
1917
+ { path: "README.md", content: buildReadme$5(collection.name, scaffoldPaths) }
1918
1918
  );
1919
1919
  return files;
1920
1920
  }
@@ -2203,7 +2203,7 @@ function buildPackageJson$2(collectionName) {
2203
2203
  }
2204
2204
  }, null, 2) + "\n";
2205
2205
  }
2206
- function renderTree$3(paths) {
2206
+ function renderTree$4(paths) {
2207
2207
  const root = {};
2208
2208
  for (const p of [...paths].sort()) {
2209
2209
  let cur = root;
@@ -2222,8 +2222,8 @@ function renderTree$3(paths) {
2222
2222
  }
2223
2223
  return [".", ...render(root)].join("\n");
2224
2224
  }
2225
- function buildReadme$3(collectionName, filePaths) {
2226
- const tree = renderTree$3([...filePaths, ".env.local"]);
2225
+ function buildReadme$4(collectionName, filePaths) {
2226
+ const tree = renderTree$4([...filePaths, ".env.local"]);
2227
2227
  return `# ${collectionName} — API Tests (Playwright JavaScript)
2228
2228
 
2229
2229
  ## Project structure
@@ -2258,7 +2258,7 @@ function generatePlaywrightJs(collection, environment) {
2258
2258
  files.unshift(
2259
2259
  { path: "package.json", content: buildPackageJson$2(collection.name) },
2260
2260
  { path: "playwright.config.js", content: buildPlaywrightConfig(environment) },
2261
- { path: "README.md", content: buildReadme$3(collection.name, scaffoldPaths) }
2261
+ { path: "README.md", content: buildReadme$4(collection.name, scaffoldPaths) }
2262
2262
  );
2263
2263
  return files;
2264
2264
  }
@@ -2486,7 +2486,7 @@ function buildTsConfig() {
2486
2486
  exclude: ["node_modules", "dist"]
2487
2487
  }, null, 2) + "\n";
2488
2488
  }
2489
- function renderTree$2(paths) {
2489
+ function renderTree$3(paths) {
2490
2490
  const root = {};
2491
2491
  for (const p of [...paths].sort()) {
2492
2492
  let cur = root;
@@ -2505,8 +2505,8 @@ function renderTree$2(paths) {
2505
2505
  }
2506
2506
  return [".", ...render(root)].join("\n");
2507
2507
  }
2508
- function buildReadme$2(collectionName, filePaths) {
2509
- const tree = renderTree$2([...filePaths, ".env.local"]);
2508
+ function buildReadme$3(collectionName, filePaths) {
2509
+ const tree = renderTree$3([...filePaths, ".env.local"]);
2510
2510
  return `# ${collectionName} — API Tests (Supertest + Jest TypeScript)
2511
2511
 
2512
2512
  ## Project structure
@@ -2551,7 +2551,7 @@ function generateSupertestTs(collection, environment) {
2551
2551
  files.unshift(
2552
2552
  { path: "package.json", content: buildPackageJson$1(collection.name) },
2553
2553
  { path: "tsconfig.json", content: buildTsConfig() },
2554
- { path: "README.md", content: buildReadme$2(collection.name, scaffoldPaths) }
2554
+ { path: "README.md", content: buildReadme$3(collection.name, scaffoldPaths) }
2555
2555
  );
2556
2556
  return files;
2557
2557
  }
@@ -2755,7 +2755,7 @@ function buildPackageJson(collectionName) {
2755
2755
  }
2756
2756
  }, null, 2) + "\n";
2757
2757
  }
2758
- function renderTree$1(paths) {
2758
+ function renderTree$2(paths) {
2759
2759
  const root = {};
2760
2760
  for (const p of [...paths].sort()) {
2761
2761
  let cur = root;
@@ -2774,8 +2774,8 @@ function renderTree$1(paths) {
2774
2774
  }
2775
2775
  return [".", ...render(root)].join("\n");
2776
2776
  }
2777
- function buildReadme$1(collectionName, filePaths) {
2778
- const tree = renderTree$1([...filePaths, ".env.local"]);
2777
+ function buildReadme$2(collectionName, filePaths) {
2778
+ const tree = renderTree$2([...filePaths, ".env.local"]);
2779
2779
  return `# ${collectionName} — API Tests (Supertest + Jest JavaScript)
2780
2780
 
2781
2781
  ## Project structure
@@ -2819,11 +2819,11 @@ function generateSupertestJs(collection, environment) {
2819
2819
  const scaffoldPaths = ["package.json", ...files.map((f) => f.path)];
2820
2820
  files.unshift(
2821
2821
  { path: "package.json", content: buildPackageJson(collection.name) },
2822
- { path: "README.md", content: buildReadme$1(collection.name, scaffoldPaths) }
2822
+ { path: "README.md", content: buildReadme$2(collection.name, scaffoldPaths) }
2823
2823
  );
2824
2824
  return files;
2825
2825
  }
2826
- function javaClass(name) {
2826
+ function javaClass$1(name) {
2827
2827
  return name.replace(/[^\w\s]/g, " ").split(/\s+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
2828
2828
  }
2829
2829
  function javaMethod(name) {
@@ -2854,7 +2854,7 @@ function interpolateJava(value, sharedVars = /* @__PURE__ */ new Set()) {
2854
2854
  return `" + System.getenv("${envKey}") + "`;
2855
2855
  }) + '"';
2856
2856
  }
2857
- function buildPom(collectionName) {
2857
+ function buildPom$1(collectionName) {
2858
2858
  const artifact = collectionName.replace(/\W+/g, "-").toLowerCase();
2859
2859
  return `<?xml version="1.0" encoding="UTF-8"?>
2860
2860
  <project xmlns="http://maven.apache.org/POM/4.0.0"
@@ -2966,7 +2966,7 @@ public class BaseTest {
2966
2966
  }
2967
2967
  function buildTestClass(folderName, folder, collection) {
2968
2968
  const requests = collection.requests;
2969
- const className = javaClass(folderName) + "Test";
2969
+ const className = javaClass$1(folderName) + "Test";
2970
2970
  const methods = [];
2971
2971
  const hooks = requestCollection.getAllApplicableHooks(folder.id, collection);
2972
2972
  const beforeAllH = hooks.beforeAll;
@@ -3122,7 +3122,7 @@ ${methods.join("\n\n")}
3122
3122
  }
3123
3123
  `;
3124
3124
  }
3125
- function renderTree(paths) {
3125
+ function renderTree$1(paths) {
3126
3126
  const root = {};
3127
3127
  for (const p of [...paths].sort()) {
3128
3128
  let cur = root;
@@ -3141,8 +3141,8 @@ function renderTree(paths) {
3141
3141
  }
3142
3142
  return [".", ...render(root)].join("\n");
3143
3143
  }
3144
- function buildReadme(collectionName, filePaths) {
3145
- const tree = renderTree(filePaths);
3144
+ function buildReadme$1(collectionName, filePaths) {
3145
+ const tree = renderTree$1(filePaths);
3146
3146
  return `# ${collectionName} — API Tests (REST Assured + JUnit 5)
3147
3147
 
3148
3148
  ## Project structure
@@ -3166,12 +3166,12 @@ BASE_URL=https://api.example.com mvn test
3166
3166
  }
3167
3167
  function generateRestAssured(collection, environment) {
3168
3168
  const files = [
3169
- { path: "pom.xml", content: buildPom(collection.name) },
3169
+ { path: "pom.xml", content: buildPom$1(collection.name) },
3170
3170
  { path: "src/test/java/com/example/api/BaseTest.java", content: buildBaseTest(environment) }
3171
3171
  ];
3172
3172
  function processFolder(folder, name) {
3173
3173
  if (folder.requestIds.length > 0) {
3174
- const className = javaClass(name) + "Test";
3174
+ const className = javaClass$1(name) + "Test";
3175
3175
  files.push({
3176
3176
  path: `src/test/java/com/example/api/${className}.java`,
3177
3177
  content: buildTestClass(name, folder, collection)
@@ -3187,6 +3187,442 @@ function generateRestAssured(collection, environment) {
3187
3187
  for (const sub of collection.rootFolder.folders) {
3188
3188
  processFolder(sub, sub.name);
3189
3189
  }
3190
+ files.unshift({ path: "README.md", content: buildReadme$1(collection.name, files.map((f) => f.path)) });
3191
+ return files;
3192
+ }
3193
+ function javaClass(name) {
3194
+ return name.replace(/[^\w\s]/g, " ").split(/\s+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
3195
+ }
3196
+ function jsVar(name) {
3197
+ const parts = name.replace(/[^a-zA-Z0-9]+/g, " ").split(/\s+/).filter(Boolean).map((p) => p.toLowerCase());
3198
+ if (parts.length === 0) return "_";
3199
+ return parts[0] + parts.slice(1).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
3200
+ }
3201
+ function featureFileName(name) {
3202
+ const slug2 = name.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").toLowerCase();
3203
+ return slug2 || "tests";
3204
+ }
3205
+ function configKey(envKey) {
3206
+ return jsVar(envKey);
3207
+ }
3208
+ function escSingle(s) {
3209
+ return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3210
+ }
3211
+ function interpolateKarate(value) {
3212
+ if (!value.includes("{{")) return `'${escSingle(value)}'`;
3213
+ const parts = [];
3214
+ let last = 0;
3215
+ const re = /\{\{([^}]+)\}\}/g;
3216
+ let m;
3217
+ while (m = re.exec(value)) {
3218
+ if (m.index > last) parts.push(`'${escSingle(value.slice(last, m.index))}'`);
3219
+ parts.push(configKey(m[1].trim()));
3220
+ last = m.index + m[0].length;
3221
+ }
3222
+ if (last < value.length) parts.push(`'${escSingle(value.slice(last))}'`);
3223
+ return parts.length === 1 ? parts[0] : parts.join(" + ");
3224
+ }
3225
+ function urlSteps(url) {
3226
+ const leadingVar = url.match(/^\{\{([^}]+)\}\}(.*)$/);
3227
+ if (leadingVar) {
3228
+ const baseVar = configKey(leadingVar[1].trim());
3229
+ const rest2 = leadingVar[2].replace(/^\//, "");
3230
+ const steps2 = [[`url ${baseVar}`]];
3231
+ if (rest2) steps2.push([`path ${interpolateKarate(rest2)}`]);
3232
+ return steps2;
3233
+ }
3234
+ if (/^https?:\/\//i.test(url)) {
3235
+ return [[`url ${interpolateKarate(url)}`]];
3236
+ }
3237
+ const rest = url.replace(/^\//, "");
3238
+ const steps = [[`url baseUrl`]];
3239
+ if (rest) steps.push([`path ${interpolateKarate(rest)}`]);
3240
+ return steps;
3241
+ }
3242
+ function buildPom(collectionName) {
3243
+ const artifact = collectionName.replace(/\W+/g, "-").toLowerCase();
3244
+ return `<?xml version="1.0" encoding="UTF-8"?>
3245
+ <project xmlns="http://maven.apache.org/POM/4.0.0"
3246
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3247
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
3248
+ http://maven.apache.org/xsd/maven-4.0.0.xsd">
3249
+ <modelVersion>4.0.0</modelVersion>
3250
+
3251
+ <groupId>com.example.api</groupId>
3252
+ <artifactId>${artifact}-karate</artifactId>
3253
+ <version>1.0.0-SNAPSHOT</version>
3254
+ <packaging>jar</packaging>
3255
+
3256
+ <properties>
3257
+ <java.version>17</java.version>
3258
+ <maven.compiler.source>\${java.version}</maven.compiler.source>
3259
+ <maven.compiler.target>\${java.version}</maven.compiler.target>
3260
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
3261
+ <karate.version>1.5.0</karate.version>
3262
+ <junit.version>5.10.2</junit.version>
3263
+ </properties>
3264
+
3265
+ <dependencies>
3266
+ <dependency>
3267
+ <groupId>io.karatelabs</groupId>
3268
+ <artifactId>karate-junit5</artifactId>
3269
+ <version>\${karate.version}</version>
3270
+ <scope>test</scope>
3271
+ </dependency>
3272
+ <dependency>
3273
+ <groupId>org.junit.jupiter</groupId>
3274
+ <artifactId>junit-jupiter</artifactId>
3275
+ <version>\${junit.version}</version>
3276
+ <scope>test</scope>
3277
+ </dependency>
3278
+ </dependencies>
3279
+
3280
+ <build>
3281
+ <plugins>
3282
+ <plugin>
3283
+ <groupId>org.apache.maven.plugins</groupId>
3284
+ <artifactId>maven-surefire-plugin</artifactId>
3285
+ <version>3.2.5</version>
3286
+ </plugin>
3287
+ </plugins>
3288
+ </build>
3289
+ </project>
3290
+ `;
3291
+ }
3292
+ function buildKarateConfig(environment) {
3293
+ const baseUrl = environment?.variables.find(
3294
+ (v) => ["base_url", "baseurl", "base-url"].includes(v.key.toLowerCase()) && !v.secret
3295
+ )?.value ?? "http://localhost:8080";
3296
+ const lines = [];
3297
+ lines.push(`function fn() {`);
3298
+ lines.push(` var env = karate.env || 'dev';`);
3299
+ lines.push(` karate.log('karate env:', env);`);
3300
+ lines.push(``);
3301
+ lines.push(` var config = {`);
3302
+ lines.push(` baseUrl: '${escSingle(baseUrl)}'`);
3303
+ const otherVars = (environment?.variables ?? []).filter((v) => {
3304
+ const k = v.key.toLowerCase();
3305
+ return v.enabled && !["base_url", "baseurl", "base-url"].includes(k);
3306
+ });
3307
+ for (const v of otherVars) {
3308
+ const key = configKey(v.key);
3309
+ const def = v.secret ? "''" : `'${escSingle(v.value ?? "")}'`;
3310
+ lines.push(`,`);
3311
+ lines.push(` ${key}: ${def}`);
3312
+ }
3313
+ lines.push(` };`);
3314
+ lines.push(``);
3315
+ lines.push(` // Allow each variable to be overridden via a process env var of the same`);
3316
+ lines.push(` // SHOUTY_SNAKE_CASE name (e.g. AUTH_TOKEN populates config.authToken).`);
3317
+ lines.push(` function envOverride(name, key) {`);
3318
+ lines.push(` var v = java.lang.System.getenv(name);`);
3319
+ lines.push(` if (v) config[key] = v;`);
3320
+ lines.push(` }`);
3321
+ lines.push(` envOverride('BASE_URL', 'baseUrl');`);
3322
+ for (const v of otherVars) {
3323
+ lines.push(` envOverride('${v.key}', '${configKey(v.key)}');`);
3324
+ }
3325
+ lines.push(``);
3326
+ lines.push(` return config;`);
3327
+ lines.push(`}`);
3328
+ return lines.join("\n") + "\n";
3329
+ }
3330
+ function buildRunner(collectionName) {
3331
+ const className = javaClass(collectionName) + "Runner";
3332
+ return `package karate;
3333
+
3334
+ import com.intuit.karate.junit5.Karate;
3335
+
3336
+ /**
3337
+ * JUnit 5 entry point — runs every .feature in the \`karate\` package
3338
+ * (this folder). Override the active env at run time with:
3339
+ *
3340
+ * mvn test -Dkarate.env=staging
3341
+ */
3342
+ public class ${className} {
3343
+
3344
+ @Karate.Test
3345
+ Karate all() {
3346
+ return Karate.run().relativeTo(getClass());
3347
+ }
3348
+ }
3349
+ `;
3350
+ }
3351
+ function buildBackground(folderId, collection) {
3352
+ const hooks = requestCollection.getAllApplicableHooks(folderId, collection);
3353
+ const lines = [];
3354
+ const defined = /* @__PURE__ */ new Set();
3355
+ for (const h of [...hooks.beforeAll, ...hooks.before]) {
3356
+ const method = h.method.toLowerCase();
3357
+ const declared = new Set((h.headers ?? []).filter((x) => x.enabled && x.key).map((x) => x.key.toLowerCase()));
3358
+ const has = (n) => declared.has(n.toLowerCase());
3359
+ lines.push(` # ${h.name}`);
3360
+ for (const block of urlSteps(h.url)) {
3361
+ lines.push(` * ${block[0]}`);
3362
+ for (let i = 1; i < block.length; i++) lines.push(block[i]);
3363
+ }
3364
+ for (const x of (h.headers ?? []).filter((x2) => x2.enabled && x2.key)) {
3365
+ lines.push(` * header ${x.key} = ${interpolateKarate(x.value)}`);
3366
+ }
3367
+ if (h.body.mode !== "none" && !["get", "head"].includes(method)) {
3368
+ for (const block of bodySteps(h.body, has)) {
3369
+ lines.push(` * ${block[0]}`);
3370
+ for (let i = 1; i < block.length; i++) lines.push(block[i]);
3371
+ }
3372
+ }
3373
+ lines.push(` * method ${method}`);
3374
+ const parsed = parsePostScript(h.postRequestScript);
3375
+ for (const e of parsed.extractions) {
3376
+ const jp = accessorToJsonPath(e.accessor).replace(/^json\.?/, "");
3377
+ const expr = jp ? `response.${jp}` : "response";
3378
+ const name = configKey(e.varName);
3379
+ defined.add(name);
3380
+ lines.push(` * def ${name} = ${expr}`);
3381
+ }
3382
+ }
3383
+ return { lines, defined };
3384
+ }
3385
+ function bodyDocstring(json) {
3386
+ const expanded = json.replace(/\{\{([^}]+)\}\}/g, (_, k) => `#(${configKey(k.trim())})`);
3387
+ let pretty = expanded.trim();
3388
+ try {
3389
+ pretty = JSON.stringify(JSON.parse(expanded), null, 2);
3390
+ } catch {
3391
+ }
3392
+ const out = [' """'];
3393
+ for (const l of pretty.split("\n")) out.push(` ${l}`);
3394
+ out.push(' """');
3395
+ return out;
3396
+ }
3397
+ function rawDocstring(text) {
3398
+ const expanded = text.replace(/\{\{([^}]+)\}\}/g, (_, k) => `#(${configKey(k.trim())})`);
3399
+ const out = [' """'];
3400
+ for (const l of expanded.replace(/\r\n/g, "\n").split("\n")) out.push(` ${l}`);
3401
+ out.push(' """');
3402
+ return out;
3403
+ }
3404
+ function bodySteps(body, alreadyHasHeader) {
3405
+ if (body.mode === "json" && body.json) {
3406
+ return [[`request`, ...bodyDocstring(body.json)]];
3407
+ }
3408
+ if (body.mode === "soap" && body.soap?.envelope) {
3409
+ const out = [];
3410
+ if (body.soap.soapAction && !alreadyHasHeader("soapaction")) {
3411
+ out.push([`header SOAPAction = '"${escSingle(body.soap.soapAction)}"'`]);
3412
+ }
3413
+ if (!alreadyHasHeader("content-type")) {
3414
+ out.push([`header Content-Type = 'text/xml; charset=utf-8'`]);
3415
+ }
3416
+ out.push([`request`, ...rawDocstring(body.soap.envelope)]);
3417
+ return out;
3418
+ }
3419
+ if (body.mode === "raw" && body.raw) {
3420
+ const out = [];
3421
+ if (body.rawContentType && !alreadyHasHeader("content-type")) {
3422
+ out.push([`header Content-Type = '${escSingle(body.rawContentType)}'`]);
3423
+ }
3424
+ out.push([`request`, ...rawDocstring(body.raw)]);
3425
+ return out;
3426
+ }
3427
+ if (body.mode === "graphql" && body.graphql) {
3428
+ const env = { query: body.graphql.query };
3429
+ if (body.graphql.variables?.trim()) {
3430
+ try {
3431
+ env.variables = JSON.parse(body.graphql.variables);
3432
+ } catch {
3433
+ }
3434
+ }
3435
+ if (body.graphql.operationName?.trim()) env.operationName = body.graphql.operationName.trim();
3436
+ return [[`request`, ...bodyDocstring(JSON.stringify(env))]];
3437
+ }
3438
+ return [];
3439
+ }
3440
+ function buildFeature(folderName, folder, collection) {
3441
+ const requests = collection.requests;
3442
+ const bg = buildBackground(folder.id, collection);
3443
+ const scenarios = [];
3444
+ const usedTags = /* @__PURE__ */ new Set();
3445
+ for (const reqId of folder.requestIds) {
3446
+ const req = requests[reqId];
3447
+ if (!req || req.disabled || req.hookType) continue;
3448
+ let tag = featureFileName(req.name);
3449
+ if (usedTags.has(tag)) {
3450
+ let i = 2;
3451
+ while (usedTags.has(`${tag}-${i}`)) i++;
3452
+ tag = `${tag}-${i}`;
3453
+ }
3454
+ usedTags.add(tag);
3455
+ const inherited = requestCollection.resolveInheritedAuthAndHeaders(reqId, collection);
3456
+ const effectiveAuth = req.auth.type !== "none" ? req.auth : inherited.auth ?? req.auth;
3457
+ const allHeaders = [...inherited.headers.filter((h) => h.enabled && h.key), ...req.headers.filter((h) => h.enabled && h.key)];
3458
+ const enabledParams = req.params.filter((p) => p.enabled && p.key);
3459
+ const method = req.method.toLowerCase();
3460
+ const hasBody = req.body.mode !== "none" && !["get", "head"].includes(method);
3461
+ const lines = [];
3462
+ lines.push(`@${tag}`);
3463
+ lines.push(`Scenario: ${req.name}`);
3464
+ const setup = [...urlSteps(req.url)];
3465
+ if (effectiveAuth.type === "bearer") {
3466
+ const token = effectiveAuth.token ?? "";
3467
+ if (token.includes("{{")) {
3468
+ const single = token.match(/^\{\{([^}]+)\}\}$/);
3469
+ if (single) {
3470
+ setup.push([`header Authorization = 'Bearer ' + ${configKey(single[1].trim())}`]);
3471
+ } else {
3472
+ setup.push([`header Authorization = 'Bearer ' + ${interpolateKarate(token)}`]);
3473
+ }
3474
+ } else if (effectiveAuth.tokenSecretRef) {
3475
+ setup.push([`header Authorization = 'Bearer ' + ${configKey(effectiveAuth.tokenSecretRef)}`]);
3476
+ } else if (token) {
3477
+ setup.push([`header Authorization = 'Bearer ${escSingle(token)}'`]);
3478
+ }
3479
+ } else if (effectiveAuth.type === "basic") {
3480
+ const user = effectiveAuth.username ?? "";
3481
+ const pass = effectiveAuth.password ?? "";
3482
+ setup.push([`configure headers = ({ Authorization: 'Basic ' + java.util.Base64.getEncoder().encodeToString((${interpolateKarate(user)} + ':' + ${interpolateKarate(pass)}).getBytes()) })`]);
3483
+ }
3484
+ for (const h of allHeaders) {
3485
+ setup.push([`header ${h.key} = ${interpolateKarate(h.value)}`]);
3486
+ }
3487
+ for (const p of enabledParams) {
3488
+ setup.push([`param ${p.key} = ${interpolateKarate(p.value)}`]);
3489
+ }
3490
+ if (hasBody) {
3491
+ const declared = new Set(allHeaders.map((h) => h.key.toLowerCase()));
3492
+ const has = (n) => declared.has(n.toLowerCase());
3493
+ for (const block of bodySteps(req.body, has)) setup.push(block);
3494
+ }
3495
+ setup.forEach((step, i) => {
3496
+ const kw = i === 0 ? "Given" : "And";
3497
+ lines.push(` ${kw} ${step[0]}`);
3498
+ for (let j = 1; j < step.length; j++) lines.push(step[j]);
3499
+ });
3500
+ lines.push(` When method ${method}`);
3501
+ const asserts = [];
3502
+ const parsed = parsePostScript(req.postRequestScript);
3503
+ let statusEmitted = false;
3504
+ if (parsed.assertions.length > 0) {
3505
+ for (const a of parsed.assertions) {
3506
+ const jp = accessorToJsonPath(a.accessor).replace(/^json\.?/, "");
3507
+ const target = jp ? `response.${jp}` : "response";
3508
+ switch (a.kind) {
3509
+ case "status":
3510
+ asserts.push(`status ${a.expected ?? 200}`);
3511
+ statusEmitted = true;
3512
+ break;
3513
+ case "equals":
3514
+ asserts.push(`match ${target} == ${a.expected}`);
3515
+ break;
3516
+ case "contains":
3517
+ asserts.push(`match ${target} contains ${a.expected}`);
3518
+ break;
3519
+ case "exists":
3520
+ asserts.push(`match ${target} != null`);
3521
+ break;
3522
+ case "type": {
3523
+ const t = (a.expected ?? "").replace(/"/g, "");
3524
+ const fuzzy = ["string", "number", "boolean", "array", "object"].includes(t) ? `'#${t}'` : `'#notnull'`;
3525
+ asserts.push(`match ${target} == ${fuzzy}`);
3526
+ break;
3527
+ }
3528
+ case "above":
3529
+ asserts.push(`match ${target} > ${a.expected ?? 0}`);
3530
+ break;
3531
+ }
3532
+ }
3533
+ }
3534
+ if (!statusEmitted) asserts.unshift(`status 200`);
3535
+ asserts.forEach((step, i) => {
3536
+ const kw = i === 0 ? "Then" : "And";
3537
+ lines.push(` ${kw} ${step}`);
3538
+ });
3539
+ scenarios.push(lines.join("\n"));
3540
+ }
3541
+ const bgBlock = bg.lines.length > 0 ? `
3542
+ Background:
3543
+ ${bg.lines.join("\n")}
3544
+ ` : "";
3545
+ return `Feature: ${folderName}
3546
+ ${bgBlock}
3547
+ ${scenarios.join("\n\n")}
3548
+ `;
3549
+ }
3550
+ function renderTree(paths) {
3551
+ const root = {};
3552
+ for (const p of [...paths].sort()) {
3553
+ let cur = root;
3554
+ for (const part of p.split("/")) {
3555
+ cur = cur[part] ??= {};
3556
+ }
3557
+ }
3558
+ function render(node, prefix = "") {
3559
+ const entries = Object.entries(node);
3560
+ return entries.flatMap(([name, children], i) => {
3561
+ const last = i === entries.length - 1;
3562
+ const lines = [`${prefix}${last ? "└── " : "├── "}${name}`];
3563
+ if (Object.keys(children).length) lines.push(...render(children, prefix + (last ? " " : "│ ")));
3564
+ return lines;
3565
+ });
3566
+ }
3567
+ return [".", ...render(root)].join("\n");
3568
+ }
3569
+ function buildReadme(collectionName, filePaths) {
3570
+ const tree = renderTree(filePaths);
3571
+ return `# ${collectionName} — API Tests (Karate + JUnit 5)
3572
+
3573
+ Karate is a BDD-flavoured API test framework that uses Gherkin feature files
3574
+ (no glue code) — see https://docs.karatelabs.io for the full reference.
3575
+
3576
+ ## Project structure
3577
+
3578
+ \`\`\`
3579
+ ${tree}
3580
+ \`\`\`
3581
+
3582
+ ## Setup
3583
+
3584
+ Requires Java 17+ and Maven 3.8+.
3585
+
3586
+ \`\`\`sh
3587
+ # Run all features
3588
+ mvn test
3589
+
3590
+ # Switch environment (read by karate-config.js)
3591
+ mvn test -Dkarate.env=staging
3592
+
3593
+ # Override individual values
3594
+ BASE_URL=https://api.example.com AUTH_TOKEN=eyJ... mvn test
3595
+
3596
+ # Filter by tag
3597
+ mvn test "-Dkarate.options=--tags @get-users"
3598
+ \`\`\`
3599
+ `;
3600
+ }
3601
+ function generateKarate(collection, environment) {
3602
+ const files = [
3603
+ { path: "pom.xml", content: buildPom(collection.name) },
3604
+ { path: "src/test/resources/karate-config.js", content: buildKarateConfig(environment) },
3605
+ {
3606
+ path: `src/test/java/karate/${javaClass(collection.name)}Runner.java`,
3607
+ content: buildRunner(collection.name)
3608
+ }
3609
+ ];
3610
+ function processFolder(folder, name) {
3611
+ if (folder.requestIds.some((id) => {
3612
+ const r = collection.requests[id];
3613
+ return r && !r.disabled && !r.hookType;
3614
+ })) {
3615
+ files.push({
3616
+ path: `src/test/resources/karate/${featureFileName(name)}.feature`,
3617
+ content: buildFeature(name, folder, collection)
3618
+ });
3619
+ }
3620
+ for (const sub of folder.folders) processFolder(sub, sub.name);
3621
+ }
3622
+ if (collection.rootFolder.requestIds.length > 0) {
3623
+ processFolder(collection.rootFolder, collection.name);
3624
+ }
3625
+ for (const sub of collection.rootFolder.folders) processFolder(sub, sub.name);
3190
3626
  files.unshift({ path: "README.md", content: buildReadme(collection.name, files.map((f) => f.path)) });
3191
3627
  return files;
3192
3628
  }
@@ -3206,6 +3642,8 @@ function registerGenerateHandlers(ipc) {
3206
3642
  return generateSupertestJs(collection, environment);
3207
3643
  case "rest_assured":
3208
3644
  return generateRestAssured(collection, environment);
3645
+ case "karate":
3646
+ return generateKarate(collection, environment);
3209
3647
  default:
3210
3648
  throw new Error(`Unknown target: ${target}`);
3211
3649
  }
@@ -3292,7 +3730,8 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3292
3730
  envVars: { ...envVars },
3293
3731
  collectionVars: { ...collectionVars },
3294
3732
  globals: { ...globals },
3295
- localVars: {}
3733
+ localVars: {},
3734
+ piiMaskPatterns
3296
3735
  });
3297
3736
  preScriptError = r.error;
3298
3737
  localVars = r.updatedLocalVars;
@@ -3330,6 +3769,25 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3330
3769
  } else if (req.body.mode === "raw" && req.body.raw) {
3331
3770
  body = authBuilder.interpolate(req.body.raw, vars);
3332
3771
  if (!headers.has("content-type")) headers.set("Content-Type", req.body.rawContentType ?? "text/plain");
3772
+ } else if (req.body.mode === "graphql" && req.body.graphql) {
3773
+ const gql = req.body.graphql;
3774
+ const gqlBody = { query: authBuilder.interpolate(gql.query, vars) };
3775
+ const rawVars = gql.variables?.trim();
3776
+ if (rawVars) {
3777
+ try {
3778
+ gqlBody.variables = JSON.parse(authBuilder.interpolate(rawVars, vars));
3779
+ } catch {
3780
+ }
3781
+ }
3782
+ if (gql.operationName?.trim()) gqlBody.operationName = gql.operationName.trim();
3783
+ body = JSON.stringify(gqlBody);
3784
+ if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
3785
+ } else if (req.body.mode === "soap" && req.body.soap) {
3786
+ body = authBuilder.interpolate(req.body.soap.envelope, vars);
3787
+ if (!headers.has("content-type")) headers.set("Content-Type", "text/xml; charset=utf-8");
3788
+ if (req.body.soap.soapAction && !headers.has("soapaction")) {
3789
+ headers.set("SOAPAction", req.body.soap.soapAction);
3790
+ }
3333
3791
  }
3334
3792
  const methodHasBody = !["GET", "HEAD"].includes(req.method);
3335
3793
  const doFetch = (h) => undici.fetch(resolvedUrl, {
@@ -3361,16 +3819,17 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3361
3819
  });
3362
3820
  const maskedBody = requestCollection.maskPii(responseBody, piiMaskPatterns);
3363
3821
  const maskedHeaders = requestCollection.maskHeaders(rawRespHeaders, piiMaskPatterns);
3364
- const response = {
3822
+ const scriptResponse = {
3365
3823
  status: fetchResp.status,
3366
3824
  statusText: fetchResp.statusText,
3367
- headers: maskedHeaders,
3368
- body: maskedBody,
3825
+ headers: rawRespHeaders,
3826
+ body: responseBody,
3369
3827
  bodySize: Buffer.byteLength(responseBody, "utf8"),
3370
3828
  durationMs
3371
3829
  };
3372
3830
  const schemaTestResults = requestCollection.buildSchemaTestResults(req.schema, responseBody);
3373
- let testResults = [...schemaTestResults];
3831
+ const protocolFaultTests = requestCollection.buildProtocolFaultTests(req.body.mode, responseBody);
3832
+ let testResults = [...schemaTestResults, ...protocolFaultTests];
3374
3833
  let consoleOutput = [];
3375
3834
  let postScriptError;
3376
3835
  if (req.postRequestScript?.trim()) {
@@ -3379,9 +3838,10 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3379
3838
  collectionVars: updatedCollectionVars,
3380
3839
  globals: updatedGlobals,
3381
3840
  localVars,
3382
- response
3841
+ response: scriptResponse,
3842
+ piiMaskPatterns
3383
3843
  });
3384
- testResults = [...schemaTestResults, ...r.testResults];
3844
+ testResults = [...schemaTestResults, ...protocolFaultTests, ...r.testResults];
3385
3845
  consoleOutput = r.consoleOutput;
3386
3846
  postScriptError = r.error;
3387
3847
  updatedEnvVars = r.updatedEnvVars;
@@ -3394,7 +3854,7 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3394
3854
  const allPassed = testResults.every((t) => t.passed);
3395
3855
  const httpFailed = fetchResp.status >= 400;
3396
3856
  const hasTests = testResults.length > 0;
3397
- const status = postScriptError ? "error" : hasTests ? allPassed ? "passed" : "failed" : httpFailed ? "failed" : "skipped";
3857
+ const status = postScriptError ? "error" : hasTests ? allPassed ? "passed" : "failed" : httpFailed ? "failed" : "passed";
3398
3858
  if (httpFailed && testResults.length === 0) {
3399
3859
  testResults = [
3400
3860
  ...testResults,
@@ -4136,10 +4596,17 @@ function registerContractHandlers(ipc) {
4136
4596
  await snapshots.deleteSnapshot(dir, relPath);
4137
4597
  });
4138
4598
  }
4599
+ const GIT_BLOCK_TIMEOUT_MS = 6e4;
4139
4600
  function git() {
4140
4601
  const dir = getWorkspaceDir();
4141
4602
  if (!dir) throw new Error("No workspace open");
4142
- return simpleGit.simpleGit(dir);
4603
+ return simpleGit.simpleGit(dir, { timeout: { block: GIT_BLOCK_TIMEOUT_MS } }).env({
4604
+ ...process.env,
4605
+ GIT_TERMINAL_PROMPT: "0",
4606
+ GIT_ASKPASS: "echo",
4607
+ SSH_ASKPASS: "echo",
4608
+ GCM_INTERACTIVE: "Never"
4609
+ });
4143
4610
  }
4144
4611
  function registerGitHandlers(ipc) {
4145
4612
  ipc.handle("git:isRepo", async () => {
@@ -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-DIsjTggj.js");
7
+ const requestCollection = require("./chunks/request-collection-CElFJzre.js");
8
8
  require("crypto");
9
9
  require("dayjs");
10
10
  require("vm");
@@ -403,7 +403,7 @@ async function loadEnvironments(workspace, dir) {
403
403
  }
404
404
  return envs;
405
405
  }
406
- async function executeRequest(req, collectionVars, envVars, globals, localVars, verbose, tls) {
406
+ async function executeRequest(req, collectionVars, envVars, globals, localVars, verbose, tls, piiMaskPatterns = []) {
407
407
  if (!req.headers) req.headers = [];
408
408
  if (!req.params) req.params = [];
409
409
  if (!req.body) req.body = { mode: "none" };
@@ -426,7 +426,8 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
426
426
  envVars: { ...envVars },
427
427
  collectionVars: { ...collectionVars },
428
428
  globals: { ...globals },
429
- localVars: { ...localVars }
429
+ localVars: { ...localVars },
430
+ piiMaskPatterns
430
431
  });
431
432
  preScriptError = r.error;
432
433
  localVars = r.updatedLocalVars;
@@ -474,6 +475,12 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
474
475
  if (gql.operationName?.trim()) gqlBody.operationName = gql.operationName.trim();
475
476
  body = JSON.stringify(gqlBody);
476
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
+ }
477
484
  }
478
485
  const dispatcher = await requestCollection.buildDispatcher(void 0, tls);
479
486
  const fetchResp = await undici.fetch(resolvedUrl, {
@@ -496,7 +503,8 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
496
503
  bodySize: Buffer.byteLength(responseBody, "utf8"),
497
504
  durationMs
498
505
  };
499
- let testResults = [];
506
+ const protocolFaultTests = requestCollection.buildProtocolFaultTests(req.body.mode, responseBody);
507
+ let testResults = [...protocolFaultTests];
500
508
  let consoleOutput = [];
501
509
  let postScriptError;
502
510
  if (req.postRequestScript?.trim()) {
@@ -505,9 +513,10 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
505
513
  collectionVars: updatedCollectionVars,
506
514
  globals: updatedGlobals,
507
515
  localVars,
508
- response
516
+ response,
517
+ piiMaskPatterns
509
518
  });
510
- testResults = r.testResults;
519
+ testResults = [...protocolFaultTests, ...r.testResults];
511
520
  consoleOutput = r.consoleOutput;
512
521
  postScriptError = r.error;
513
522
  updatedEnvVars = r.updatedEnvVars;
@@ -521,7 +530,7 @@ async function executeRequest(req, collectionVars, envVars, globals, localVars,
521
530
  const allPassed = testResults.every((t) => t.passed);
522
531
  const httpFailed = fetchResp.status >= 400;
523
532
  const hasTests = testResults.length > 0;
524
- const status = postScriptError ? "error" : hasTests ? allPassed ? "passed" : "failed" : httpFailed ? "failed" : "skipped";
533
+ const status = postScriptError ? "error" : hasTests ? allPassed ? "passed" : "failed" : httpFailed ? "failed" : "passed";
525
534
  if (httpFailed && testResults.length === 0) {
526
535
  testResults = [
527
536
  ...testResults,
@@ -575,7 +584,8 @@ function printResult(r, verbose) {
575
584
  const http = r.httpStatus ? color(` ${r.httpStatus}`, r.httpStatus < 400 ? C.green : C.red) : "";
576
585
  const dur = r.durationMs !== void 0 ? color(` ${r.durationMs}ms`, C.gray) : "";
577
586
  const method = color(r.method.padEnd(7), C.cyan);
578
- 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}`);
579
589
  if (verbose) console.log(color(` ${r.resolvedUrl}`, C.gray));
580
590
  if (r.testResults?.length) {
581
591
  for (const t of r.testResults) {
@@ -622,8 +632,9 @@ async function main() {
622
632
  if (envName && !env) {
623
633
  console.warn(color(`Warning: environment "${envName}" not found. Running without environment.`, C.yellow));
624
634
  }
635
+ const version = `v${"0.2.6"}`;
625
636
  console.log("");
626
- console.log(color(" API Test Runner", C.bold, C.white));
637
+ console.log(color(" API Test Runner" + (version ? ` ${version}` : ""), C.bold, C.white));
627
638
  console.log(color(` Workspace: ${wsPath}`, C.gray));
628
639
  console.log(color(` Environment: ${env?.name ?? "(none)"}`, C.gray));
629
640
  if (filterTags.length) console.log(color(` Tags: ${filterTags.join(", ")}`, C.gray));
@@ -635,16 +646,21 @@ async function main() {
635
646
  for (const secret of secretValuesToMask) out = out.split(secret).join("***");
636
647
  return out;
637
648
  }
649
+ const DEFAULT_PII_PATTERNS = ["authorization", "password", "token", "secret", "api-key", "x-api-key"];
650
+ const piiPatterns = workspace.settings?.piiMaskPatterns ?? DEFAULT_PII_PATTERNS;
638
651
  function maskResult(r) {
639
652
  return {
640
653
  ...r,
641
654
  sentRequest: r.sentRequest ? {
642
- headers: Object.fromEntries(Object.entries(r.sentRequest.headers).map(([k, v]) => [k, redact(v)])),
643
- 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
644
659
  } : void 0,
645
660
  receivedResponse: r.receivedResponse ? {
646
661
  ...r.receivedResponse,
647
- body: redact(r.receivedResponse.body)
662
+ headers: requestCollection.maskHeaders(r.receivedResponse.headers, piiPatterns),
663
+ body: redact(requestCollection.maskPii(r.receivedResponse.body, piiPatterns))
648
664
  } : void 0
649
665
  };
650
666
  }
@@ -655,7 +671,7 @@ async function main() {
655
671
  let firstColName;
656
672
  for (const col of collections) {
657
673
  if (colName && col.name.toLowerCase() !== colName.toLowerCase()) continue;
658
- const items = requestCollection.collectTagged(col.rootFolder, col.requests, col.collectionVariables ?? {}, filterTags);
674
+ const items = requestCollection.buildRunPlan(col, null, filterTags);
659
675
  if (items.length === 0) continue;
660
676
  if (!firstColName) firstColName = col.name;
661
677
  let runEnvVars = await authBuilder.buildEnvVars(env);
@@ -678,21 +694,74 @@ async function main() {
678
694
  }
679
695
  let bailed = false;
680
696
  let lastPrintedScope = null;
697
+ const failedScopes = /* @__PURE__ */ new Set();
698
+ const skipRequests = /* @__PURE__ */ new Set();
681
699
  for (const item of items) {
682
- const { result, updatedEnvVars, updatedCollectionVars, updatedGlobals, updatedLocalVars } = await executeRequest(
683
- item.request,
684
- { ...item.collectionVars, ...runCollectionVars },
685
- runEnvVars,
686
- runGlobals,
687
- { ...runLocalVars },
688
- verbose,
689
- effectiveTls
690
- );
691
- runEnvVars = updatedEnvVars;
692
- runCollectionVars = updatedCollectionVars;
693
- runGlobals = updatedGlobals;
694
- runLocalVars = updatedLocalVars;
695
- 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
+ }
696
765
  const scopeKey = (item.scopePath ?? []).join(" / ");
697
766
  if (scopeKey !== lastPrintedScope) {
698
767
  if (scopeKey) console.log(color(` ${scopeKey}`, C.gray, C.bold));
@@ -14123,19 +14123,32 @@ const useStore = create()(
14123
14123
  }),
14124
14124
  // ── Apply script results back to store ────────────────────────────────────
14125
14125
  applyScriptUpdates: (result) => set2((s) => {
14126
+ const isMaskSentinel = (v) => v === "[REDACTED]" || v === "[*****]";
14127
+ const safeFilter = (m) => {
14128
+ const out = {};
14129
+ for (const [k, v] of Object.entries(m)) {
14130
+ if (!isMaskSentinel(v)) out[k] = v;
14131
+ else console.warn(`applyScriptUpdates: refusing to persist mask sentinel into "${k}"`);
14132
+ }
14133
+ return out;
14134
+ };
14135
+ const safeColVars = safeFilter(result.updatedCollectionVars);
14136
+ const safeEnvVars = safeFilter(result.updatedEnvVars);
14137
+ const safeGlobals = safeFilter(result.updatedGlobals);
14138
+ const safeLocalVars = safeFilter(result.updatedLocalVars);
14126
14139
  const activeColId = s.activeCollectionId;
14127
14140
  if (activeColId && s.collections[activeColId]) {
14128
14141
  const col = s.collections[activeColId].data;
14129
14142
  col.collectionVariables = {
14130
14143
  ...col.collectionVariables ?? {},
14131
- ...result.updatedCollectionVars
14144
+ ...safeColVars
14132
14145
  };
14133
14146
  s.collections[activeColId].dirty = true;
14134
14147
  }
14135
14148
  const activeEnvId = s.activeEnvironmentId;
14136
14149
  if (activeEnvId && s.environments[activeEnvId]) {
14137
14150
  const env = s.environments[activeEnvId].data;
14138
- for (const [key, value] of Object.entries(result.updatedEnvVars)) {
14151
+ for (const [key, value] of Object.entries(safeEnvVars)) {
14139
14152
  const existing = env.variables.find((v) => v.key === key);
14140
14153
  if (existing && !existing.secret) {
14141
14154
  existing.value = value;
@@ -14144,8 +14157,8 @@ const useStore = create()(
14144
14157
  }
14145
14158
  }
14146
14159
  }
14147
- s.globals = { ...s.globals, ...result.updatedGlobals };
14148
- s.sessionVars = { ...s.sessionVars, ...result.updatedLocalVars };
14160
+ s.globals = { ...s.globals, ...safeGlobals };
14161
+ s.sessionVars = { ...s.sessionVars, ...safeLocalVars };
14149
14162
  }),
14150
14163
  // ── Theme & zoom ──────────────────────────────────────────────────────────
14151
14164
  // Persisted in workspace.settings when a workspace is open; otherwise
@@ -64963,7 +64976,8 @@ const TARGETS = [
64963
64976
  { id: "playwright_js", label: "Playwright JS", description: "JavaScript page-object API classes + spec files" },
64964
64977
  { id: "supertest_ts", label: "Supertest TS", description: "Jest + Supertest TypeScript tests" },
64965
64978
  { id: "supertest_js", label: "Supertest JS", description: "Jest + Supertest JavaScript tests" },
64966
- { id: "rest_assured", label: "REST Assured", description: "Java + JUnit 5 + Maven pom.xml" }
64979
+ { id: "rest_assured", label: "REST Assured", description: "Java + JUnit 5 + Maven pom.xml" },
64980
+ { id: "karate", label: "Karate", description: "Karate feature files + JUnit 5 runner + Maven" }
64967
64981
  ];
64968
64982
  function GeneratorPanel() {
64969
64983
  const setShowGeneratorPanel = useStore((s) => s.setShowGeneratorPanel);
@@ -70968,7 +70982,7 @@ function App() {
70968
70982
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { style: { color: "#6aa3c8" }, children: "Spector" }),
70969
70983
  /* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: "ml-2 text-[10px] font-normal opacity-50", children: [
70970
70984
  "v",
70971
- "0.2.4"
70985
+ "0.2.6"
70972
70986
  ] }),
70973
70987
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "mx-2 opacity-30", children: "·" }),
70974
70988
  /* @__PURE__ */ jsxRuntimeExports.jsx(
@@ -5,7 +5,7 @@
5
5
  <meta charset="UTF-8" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>API Spector</title>
8
- <script type="module" crossorigin src="./assets/index-BC1srylp.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-jdRfbs9b.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="./assets/index-DfkLUeA1.css">
10
10
  </head>
11
11
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@testsmith/api-spector",
3
3
  "productName": "API Spector",
4
- "version": "0.2.4",
4
+ "version": "0.2.6",
5
5
  "description": "Local-first API testing tool to inspect, test and mock APIs",
6
6
  "repository": {
7
7
  "type": "git",