@testsmith/api-spector 0.2.2 → 0.2.4

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/out/main/index.js CHANGED
@@ -25,24 +25,29 @@ 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-Dx0ZqB54.js");
28
+ const requestCollection = require("./chunks/request-collection-DIsjTggj.js");
29
+ const authBuilder = require("./chunks/auth-builder-B7-LgcGr.js");
29
30
  const uuid = require("uuid");
30
31
  const jsYaml = require("js-yaml");
31
32
  const undici = require("undici");
32
33
  const JSZip = require("jszip");
33
- const mockServer = require("./chunks/mock-server-Cx-xG4pJ.js");
34
+ const mockServer = require("./chunks/mock-server-DmdvwCgj.js");
34
35
  const http = require("http");
35
36
  const WebSocket = require("ws");
36
- const https = require("https");
37
- const Ajv = require("ajv");
37
+ const soapHandler = require("./chunks/soap-handler-Cpj-JwyA.js");
38
+ const os = require("os");
39
+ const crypto = require("crypto");
40
+ const snapshots = require("./chunks/snapshots-C7YbGHM7.js");
41
+ const ipcValidate = require("./chunks/ipc-validate-CscN4HfG.js");
38
42
  const simpleGit = require("simple-git");
39
43
  const recorder = require("./chunks/recorder-DFxJgn9c.js");
40
- require("crypto");
41
- require("dayjs");
42
44
  require("vm");
45
+ require("dayjs");
43
46
  require("tv4");
44
47
  require("jsonpath-plus");
45
48
  require("@xmldom/xmldom");
49
+ require("ajv");
50
+ require("https");
46
51
  const LAST_WS_FILE = path.join(electron.app.getPath("userData"), "last-workspace.json");
47
52
  async function saveLastWorkspacePath(wsPath) {
48
53
  await promises.writeFile(LAST_WS_FILE, JSON.stringify({ path: wsPath }), "utf8");
@@ -60,10 +65,156 @@ let workspaceFile = null;
60
65
  function atomicWrite(path2, data) {
61
66
  return promises.writeFile(path2, data, "utf8");
62
67
  }
68
+ const SPECTOR_GITIGNORE = [
69
+ "# API Spector — never commit secrets",
70
+ "*.secrets",
71
+ ".env",
72
+ ".env.local",
73
+ ".env.*.local",
74
+ "",
75
+ "# Dependencies",
76
+ "node_modules/",
77
+ "",
78
+ "# Generated API docs",
79
+ "api-docs.html",
80
+ "api-docs.md",
81
+ "docs/",
82
+ "",
83
+ "# Run reports (api-spector run --output)",
84
+ "reports/",
85
+ "*-report.json",
86
+ "*-report.xml",
87
+ "*-report.html",
88
+ "results.json",
89
+ "results.xml",
90
+ "results.html",
91
+ "coverage/",
92
+ "",
93
+ "# OS / editor",
94
+ ".DS_Store",
95
+ "Thumbs.db",
96
+ ".idea/",
97
+ ""
98
+ ].join("\n");
99
+ function readmeContents(workspaceFileName) {
100
+ return [
101
+ `# API tests`,
102
+ ``,
103
+ `This folder is an [API Spector](https://github.com/testsmith-io/api-spector) workspace.`,
104
+ `Everything here is plain JSON — diff it, commit it, review it like any other code.`,
105
+ ``,
106
+ `## Layout`,
107
+ ``,
108
+ "```",
109
+ `${workspaceFileName} ← workspace manifest (this is what you "open")`,
110
+ `collections/ ← your request collections`,
111
+ `environments/ ← per-env variable files (dev, staging, prod, …)`,
112
+ `mocks/ ← saved mock servers (optional)`,
113
+ `contracts/ ← pinned OpenAPI snapshots (optional)`,
114
+ `.gitignore ← excludes secrets, generated docs, run reports`,
115
+ `.vscode/settings.json ← maps *.spector to JSON for editor highlighting`,
116
+ "```",
117
+ ``,
118
+ `## Open the workspace`,
119
+ ``,
120
+ "```bash",
121
+ `# launches the GUI in this folder`,
122
+ `npx -y @testsmith/api-spector ui`,
123
+ "```",
124
+ ``,
125
+ `## Run the tests from CI`,
126
+ ``,
127
+ "```bash",
128
+ `npx -y @testsmith/api-spector run \\`,
129
+ ` --workspace ./${workspaceFileName} \\`,
130
+ ` --environment ci \\`,
131
+ ` --output reports/results.xml --format junit`,
132
+ "```",
133
+ ``,
134
+ `Other useful commands:`,
135
+ ``,
136
+ `| Command | What it does |`,
137
+ `|---|---|`,
138
+ `| \`api-spector run\` | Execute requests / assertions |`,
139
+ `| \`api-spector mock\` | Start mock servers from this workspace |`,
140
+ `| \`api-spector contract\` | Manage and run pinned contract snapshots |`,
141
+ `| \`api-spector wsdl\` | Inspect a WSDL or import as collection / mock |`,
142
+ ``,
143
+ `## A note on secrets`,
144
+ ``,
145
+ `Secret values (passwords, OAuth client secrets, API keys) are stored in your`,
146
+ `OS keychain — **not** in this folder. Environment files only reference the`,
147
+ `keychain entry by name, so it's safe to commit them.`,
148
+ ``
149
+ ].join("\n");
150
+ }
151
+ async function ensureReadme(workspaceDir2, workspaceFileName) {
152
+ const path$1 = path.join(workspaceDir2, "README.md");
153
+ try {
154
+ await promises.readFile(path$1, "utf8");
155
+ return;
156
+ } catch (err) {
157
+ if (err.code !== "ENOENT") {
158
+ console.warn("file-handler: could not check README.md", err);
159
+ return;
160
+ }
161
+ await atomicWrite(path$1, readmeContents(workspaceFileName));
162
+ }
163
+ }
164
+ async function ensureGitignore(workspaceDir2) {
165
+ const path$1 = path.join(workspaceDir2, ".gitignore");
166
+ try {
167
+ await promises.readFile(path$1, "utf8");
168
+ return;
169
+ } catch (err) {
170
+ if (err.code !== "ENOENT") {
171
+ console.warn("file-handler: could not check .gitignore", err);
172
+ return;
173
+ }
174
+ await atomicWrite(path$1, SPECTOR_GITIGNORE);
175
+ }
176
+ }
177
+ async function ensureVscodeFileAssociation(workspaceDir2) {
178
+ const dir = path.join(workspaceDir2, ".vscode");
179
+ const file = path.join(dir, "settings.json");
180
+ try {
181
+ const raw = await promises.readFile(file, "utf8");
182
+ let parsed;
183
+ try {
184
+ parsed = JSON.parse(raw);
185
+ } catch {
186
+ return;
187
+ }
188
+ const associations = parsed["files.associations"] ?? {};
189
+ if (associations["*.spector"] === "json") return;
190
+ parsed["files.associations"] = { ...associations, "*.spector": "json" };
191
+ await atomicWrite(file, JSON.stringify(parsed, null, 2) + "\n");
192
+ } catch (err) {
193
+ if (err.code !== "ENOENT") {
194
+ console.warn("file-handler: could not read existing .vscode/settings.json", err);
195
+ return;
196
+ }
197
+ await promises.mkdir(dir, { recursive: true });
198
+ const fresh = { "files.associations": { "*.spector": "json" } };
199
+ await atomicWrite(file, JSON.stringify(fresh, null, 2) + "\n");
200
+ }
201
+ }
202
+ function dialogStartDir() {
203
+ return process.env.API_SPECTOR_LAUNCH_CWD || workspaceDir || void 0;
204
+ }
205
+ function defaultWorkspaceName() {
206
+ const cwd = process.env.API_SPECTOR_LAUNCH_CWD;
207
+ if (cwd) {
208
+ const base = cwd.split(/[\\/]/).filter(Boolean).pop();
209
+ if (base && /^[a-zA-Z0-9._-]+$/.test(base)) return `${base}.spector`;
210
+ }
211
+ return "my-workspace.spector";
212
+ }
63
213
  function registerFileHandlers(ipc) {
64
214
  ipc.handle("file:openWorkspace", async () => {
65
215
  const result = await electron.dialog.showOpenDialog({
66
216
  title: "Open Workspace",
217
+ defaultPath: dialogStartDir(),
67
218
  filters: [{ name: "API Spector Workspace", extensions: ["spector", "json"] }],
68
219
  properties: ["openFile"]
69
220
  });
@@ -77,9 +228,11 @@ function registerFileHandlers(ipc) {
77
228
  return { workspace: JSON.parse(raw), workspacePath: wsPath };
78
229
  });
79
230
  ipc.handle("file:newWorkspace", async () => {
231
+ const startDir = dialogStartDir();
232
+ const defaultPath = startDir ? path.join(startDir, defaultWorkspaceName()) : defaultWorkspaceName();
80
233
  const result = await electron.dialog.showSaveDialog({
81
234
  title: "Create Workspace",
82
- defaultPath: "my-workspace.spector",
235
+ defaultPath,
83
236
  filters: [{ name: "API Spector Workspace", extensions: ["spector", "json"] }]
84
237
  });
85
238
  if (result.canceled || !result.filePath) return null;
@@ -88,8 +241,9 @@ function registerFileHandlers(ipc) {
88
241
  await requestCollection.loadGlobals(workspaceDir);
89
242
  await promises.mkdir(path.join(workspaceDir, "collections"), { recursive: true });
90
243
  await promises.mkdir(path.join(workspaceDir, "environments"), { recursive: true });
91
- const gitignore = "# API Spector — never commit secrets\n*.secrets\n.env.local\n\n# Dependencies\nnode_modules/\n";
92
- await atomicWrite(path.join(workspaceDir, ".gitignore"), gitignore);
244
+ await ensureGitignore(workspaceDir);
245
+ await ensureVscodeFileAssociation(workspaceDir);
246
+ await ensureReadme(workspaceDir, path.basename(result.filePath));
93
247
  const ws = {
94
248
  version: "1.0",
95
249
  collections: [],
@@ -103,6 +257,7 @@ function registerFileHandlers(ipc) {
103
257
  ipc.handle("file:saveWorkspace", async (_e, ws) => {
104
258
  if (!workspaceFile) return;
105
259
  await atomicWrite(workspaceFile, JSON.stringify(ws, null, 2));
260
+ if (workspaceDir) await ensureVscodeFileAssociation(workspaceDir);
106
261
  });
107
262
  ipc.handle("file:loadCollection", async (_e, relPath) => {
108
263
  if (!workspaceDir) throw new Error("No workspace open");
@@ -126,6 +281,18 @@ function registerFileHandlers(ipc) {
126
281
  await promises.mkdir(path.dirname(fullPath), { recursive: true });
127
282
  await atomicWrite(fullPath, JSON.stringify(env, null, 2));
128
283
  });
284
+ ipc.handle("file:deleteWorkspaceFile", async (_e, relPath) => {
285
+ if (!workspaceDir) throw new Error("No workspace open");
286
+ const fullPath = path.resolve(workspaceDir, relPath);
287
+ if (!fullPath.startsWith(path.resolve(workspaceDir) + (process.platform === "win32" ? "\\" : "/"))) {
288
+ throw new Error(`Refusing to delete file outside the workspace: ${relPath}`);
289
+ }
290
+ try {
291
+ await promises.unlink(fullPath);
292
+ } catch (err) {
293
+ if (err.code !== "ENOENT") throw err;
294
+ }
295
+ });
129
296
  ipc.handle("dialog:pickDir", async () => {
130
297
  const result = await electron.dialog.showOpenDialog({
131
298
  title: "Select Output Directory",
@@ -157,23 +324,51 @@ function registerFileHandlers(ipc) {
157
324
  ipc.handle("file:closeWorkspace", async () => {
158
325
  workspaceDir = null;
159
326
  workspaceFile = null;
160
- await promises.writeFile(LAST_WS_FILE, JSON.stringify({ path: null }), "utf8").catch(() => {
327
+ await promises.writeFile(LAST_WS_FILE, JSON.stringify({ path: null }), "utf8").catch((err) => {
328
+ console.warn("file-handler: could not clear last-workspace pointer", err);
161
329
  });
162
330
  });
163
331
  ipc.handle("file:getLastWorkspace", async () => {
332
+ const cwd = process.env.API_SPECTOR_LAUNCH_CWD;
333
+ if (cwd) {
334
+ const fromCwd = await tryOpenWorkspaceInDir(cwd);
335
+ return fromCwd;
336
+ }
164
337
  const wsPath = await readLastWorkspacePath();
165
338
  if (!wsPath) return null;
166
339
  try {
340
+ const raw = await promises.readFile(wsPath, "utf8");
341
+ const workspace = JSON.parse(raw);
167
342
  workspaceDir = path.dirname(wsPath);
168
343
  workspaceFile = wsPath;
169
344
  await requestCollection.loadGlobals(workspaceDir);
170
- const raw = await promises.readFile(wsPath, "utf8");
171
- return { workspace: JSON.parse(raw), workspacePath: wsPath };
345
+ return { workspace, workspacePath: wsPath };
172
346
  } catch {
173
347
  return null;
174
348
  }
175
349
  });
176
350
  }
351
+ async function tryOpenWorkspaceInDir(dir) {
352
+ let entries;
353
+ try {
354
+ entries = await promises.readdir(dir);
355
+ } catch {
356
+ return null;
357
+ }
358
+ const candidates = entries.filter((f) => f.endsWith(".spector"));
359
+ if (candidates.length !== 1) return null;
360
+ const wsPath = path.join(dir, candidates[0]);
361
+ try {
362
+ const raw = await promises.readFile(wsPath, "utf8");
363
+ const workspace = JSON.parse(raw);
364
+ workspaceDir = dir;
365
+ workspaceFile = wsPath;
366
+ await requestCollection.loadGlobals(workspaceDir);
367
+ return { workspace, workspacePath: wsPath };
368
+ } catch {
369
+ return null;
370
+ }
371
+ }
177
372
  function getWorkspaceDir() {
178
373
  return workspaceDir;
179
374
  }
@@ -399,7 +594,7 @@ async function importPostman(filePath) {
399
594
  requests
400
595
  };
401
596
  }
402
- async function loadSpec$1(filePath) {
597
+ async function loadSpec(filePath) {
403
598
  const raw = await promises.readFile(filePath, "utf8");
404
599
  if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {
405
600
  return jsYaml.load(raw);
@@ -416,7 +611,7 @@ async function loadSpecFromUrl(url) {
416
611
  }
417
612
  return JSON.parse(text);
418
613
  }
419
- function resolveRef$1(spec, ref) {
614
+ function resolveRef(spec, ref) {
420
615
  const parts = ref.replace(/^#\//, "").split("/");
421
616
  return parts.reduce((obj, key) => obj?.[decodeURIComponent(key.replace(/~1/g, "/").replace(/~0/g, "~"))], spec);
422
617
  }
@@ -428,7 +623,7 @@ function resolve(spec, obj, seen = /* @__PURE__ */ new Set()) {
428
623
  return obj.map((item) => resolve(spec, item, seen));
429
624
  }
430
625
  if ("$ref" in obj) {
431
- const target = resolveRef$1(spec, obj.$ref);
626
+ const target = resolveRef(spec, obj.$ref);
432
627
  if (!target || seen.has(target)) return {};
433
628
  return resolve(spec, target, seen);
434
629
  }
@@ -626,7 +821,7 @@ function buildCollection(spec) {
626
821
  };
627
822
  }
628
823
  async function importOpenApi(filePath) {
629
- return buildCollection(await loadSpec$1(filePath));
824
+ return buildCollection(await loadSpec(filePath));
630
825
  }
631
826
  async function importOpenApiFromUrl(url) {
632
827
  return buildCollection(await loadSpecFromUrl(url));
@@ -652,7 +847,7 @@ function extractSchemas(spec) {
652
847
  return entries;
653
848
  }
654
849
  async function extractSchemasFromFile(filePath) {
655
- return extractSchemas(await loadSpec$1(filePath));
850
+ return extractSchemas(await loadSpec(filePath));
656
851
  }
657
852
  async function extractSchemasFromUrl(url) {
658
853
  return extractSchemas(await loadSpecFromUrl(url));
@@ -3086,14 +3281,14 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3086
3281
  resolvedUrl: "",
3087
3282
  status: "running"
3088
3283
  };
3089
- const dynamicVars = await requestCollection.buildDynamicVars();
3090
- let vars = requestCollection.mergeVars(envVars, collectionVars, globals, localVars, dynamicVars);
3284
+ const dynamicVars = await authBuilder.buildDynamicVars();
3285
+ let vars = authBuilder.mergeVars(envVars, collectionVars, globals, localVars, dynamicVars);
3091
3286
  let updatedEnvVars = { ...envVars };
3092
3287
  let updatedCollectionVars = { ...collectionVars };
3093
3288
  let updatedGlobals = { ...globals };
3094
3289
  let preScriptError;
3095
3290
  if (req.preRequestScript?.trim()) {
3096
- const r = await requestCollection.runScript(requestCollection.interpolate(req.preRequestScript, vars), {
3291
+ const r = await requestCollection.runScript(authBuilder.interpolate(req.preRequestScript, vars), {
3097
3292
  envVars: { ...envVars },
3098
3293
  collectionVars: { ...collectionVars },
3099
3294
  globals: { ...globals },
@@ -3106,9 +3301,9 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3106
3301
  updatedGlobals = r.updatedGlobals;
3107
3302
  requestCollection.patchGlobals(r.updatedGlobals);
3108
3303
  await requestCollection.persistGlobals();
3109
- vars = requestCollection.mergeVars(updatedEnvVars, updatedCollectionVars, updatedGlobals, localVars, dynamicVars);
3304
+ vars = authBuilder.mergeVars(updatedEnvVars, updatedCollectionVars, updatedGlobals, localVars, dynamicVars);
3110
3305
  }
3111
- const resolvedUrl = requestCollection.buildUrl(req.url, req.params, vars);
3306
+ const resolvedUrl = authBuilder.buildUrl(req.url, req.params, vars);
3112
3307
  base.resolvedUrl = resolvedUrl;
3113
3308
  const start = Date.now();
3114
3309
  try {
@@ -3117,23 +3312,23 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3117
3312
  const tokenMissing = !req.auth.oauth2CachedToken;
3118
3313
  const tokenExpired = req.auth.oauth2TokenExpiry ? req.auth.oauth2TokenExpiry <= now + 5e3 : true;
3119
3314
  if (tokenMissing || tokenExpired) {
3120
- const result = await requestCollection.fetchOAuth2Token(req.auth, vars);
3315
+ const result = await authBuilder.fetchOAuth2Token(req.auth, vars);
3121
3316
  req.auth.oauth2CachedToken = result.accessToken;
3122
3317
  req.auth.oauth2TokenExpiry = result.expiresAt;
3123
3318
  }
3124
3319
  }
3125
- const authHeaders = await requestCollection.buildAuthHeaders(req.auth, vars);
3320
+ const authHeaders = await authBuilder.buildAuthHeaders(req.auth, vars);
3126
3321
  const headers = new undici.Headers();
3127
3322
  for (const h of req.headers) {
3128
- if (h.enabled && h.key) headers.set(requestCollection.interpolate(h.key, vars), requestCollection.interpolate(h.value, vars));
3323
+ if (h.enabled && h.key) headers.set(authBuilder.interpolate(h.key, vars), authBuilder.interpolate(h.value, vars));
3129
3324
  }
3130
3325
  for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
3131
3326
  let body;
3132
3327
  if (req.body.mode === "json" && req.body.json) {
3133
- body = requestCollection.interpolate(req.body.json, vars);
3328
+ body = authBuilder.interpolate(req.body.json, vars);
3134
3329
  if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
3135
3330
  } else if (req.body.mode === "raw" && req.body.raw) {
3136
- body = requestCollection.interpolate(req.body.raw, vars);
3331
+ body = authBuilder.interpolate(req.body.raw, vars);
3137
3332
  if (!headers.has("content-type")) headers.set("Content-Type", req.body.rawContentType ?? "text/plain");
3138
3333
  }
3139
3334
  const methodHasBody = !["GET", "HEAD"].includes(req.method);
@@ -3145,14 +3340,14 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3145
3340
  });
3146
3341
  let fetchResp;
3147
3342
  if (req.auth.type === "ntlm") {
3148
- await requestCollection.performNtlmRequest(resolvedUrl, req.method, req.auth, vars);
3343
+ await authBuilder.performNtlmRequest(resolvedUrl, req.method, req.auth, vars);
3149
3344
  fetchResp = await doFetch(headers);
3150
3345
  } else if (req.auth.type === "digest") {
3151
3346
  const probeFetch = (url, init) => undici.fetch(url, {
3152
3347
  ...init,
3153
3348
  dispatcher
3154
3349
  });
3155
- const digestHeader = await requestCollection.performDigestAuth(resolvedUrl, req.method, req.auth, vars, probeFetch);
3350
+ const digestHeader = await authBuilder.performDigestAuth(resolvedUrl, req.method, req.auth, vars, probeFetch);
3156
3351
  if (digestHeader) headers.set("Authorization", digestHeader);
3157
3352
  fetchResp = await doFetch(headers);
3158
3353
  } else {
@@ -3179,7 +3374,7 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3179
3374
  let consoleOutput = [];
3180
3375
  let postScriptError;
3181
3376
  if (req.postRequestScript?.trim()) {
3182
- const r = await requestCollection.runScript(requestCollection.interpolate(req.postRequestScript, vars), {
3377
+ const r = await requestCollection.runScript(authBuilder.interpolate(req.postRequestScript, vars), {
3183
3378
  envVars: updatedEnvVars,
3184
3379
  collectionVars: updatedCollectionVars,
3185
3380
  globals: updatedGlobals,
@@ -3257,7 +3452,7 @@ const sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
3257
3452
  function registerRunnerHandler(ipc) {
3258
3453
  ipc.handle("runner:start", async (event, payload) => {
3259
3454
  const { items, environment, globals: payloadGlobals, proxy, tls, piiMaskPatterns = [], requestDelay = 0 } = payload;
3260
- const envVars = await requestCollection.buildEnvVars(environment);
3455
+ const envVars = await authBuilder.buildEnvVars(environment);
3261
3456
  const liveGlobals = requestCollection.getGlobals();
3262
3457
  const globals = { ...payloadGlobals, ...liveGlobals };
3263
3458
  const dispatcher = await buildDispatcher(proxy, tls);
@@ -3392,14 +3587,14 @@ function registerOAuth2Handlers(ipc) {
3392
3587
  ipc.handle("oauth2:startFlow", async (_e, auth, vars) => {
3393
3588
  const port = auth.oauth2RedirectPort ?? 9876;
3394
3589
  const redirectUri = `http://localhost:${port}/callback`;
3395
- const authUrl = requestCollection.interpolate(auth.oauth2AuthUrl ?? "", vars);
3396
- const tokenUrl = requestCollection.interpolate(auth.oauth2TokenUrl ?? "", vars);
3397
- const clientId = requestCollection.interpolate(auth.oauth2ClientId ?? "", vars);
3590
+ const authUrl = authBuilder.interpolate(auth.oauth2AuthUrl ?? "", vars);
3591
+ const tokenUrl = authBuilder.interpolate(auth.oauth2TokenUrl ?? "", vars);
3592
+ const clientId = authBuilder.interpolate(auth.oauth2ClientId ?? "", vars);
3398
3593
  let clientSecret = auth.oauth2ClientSecret ?? "";
3399
3594
  if (!clientSecret && auth.oauth2ClientSecretRef) {
3400
- clientSecret = await requestCollection.getSecret(auth.oauth2ClientSecretRef) ?? "";
3595
+ clientSecret = await authBuilder.getSecret(auth.oauth2ClientSecretRef) ?? "";
3401
3596
  }
3402
- clientSecret = requestCollection.interpolate(clientSecret, vars);
3597
+ clientSecret = authBuilder.interpolate(clientSecret, vars);
3403
3598
  if (!authUrl) throw new Error("OAuth 2.0: authUrl is required for authorization_code flow.");
3404
3599
  if (!tokenUrl) throw new Error("OAuth 2.0: tokenUrl is required for authorization_code flow.");
3405
3600
  if (!clientId) throw new Error("OAuth 2.0: clientId is required.");
@@ -3474,13 +3669,13 @@ function registerOAuth2Handlers(ipc) {
3474
3669
  };
3475
3670
  });
3476
3671
  ipc.handle("oauth2:refreshToken", async (_e, auth, vars, refreshToken) => {
3477
- const tokenUrl = requestCollection.interpolate(auth.oauth2TokenUrl ?? "", vars);
3478
- const clientId = requestCollection.interpolate(auth.oauth2ClientId ?? "", vars);
3672
+ const tokenUrl = authBuilder.interpolate(auth.oauth2TokenUrl ?? "", vars);
3673
+ const clientId = authBuilder.interpolate(auth.oauth2ClientId ?? "", vars);
3479
3674
  let clientSecret = auth.oauth2ClientSecret ?? "";
3480
3675
  if (!clientSecret && auth.oauth2ClientSecretRef) {
3481
- clientSecret = await requestCollection.getSecret(auth.oauth2ClientSecretRef) ?? "";
3676
+ clientSecret = await authBuilder.getSecret(auth.oauth2ClientSecretRef) ?? "";
3482
3677
  }
3483
- clientSecret = requestCollection.interpolate(clientSecret, vars);
3678
+ clientSecret = authBuilder.interpolate(clientSecret, vars);
3484
3679
  if (!tokenUrl) throw new Error("OAuth 2.0: tokenUrl is required for refresh.");
3485
3680
  const { fetch: nodeFetch } = await import("undici");
3486
3681
  const params = new URLSearchParams();
@@ -3564,70 +3759,31 @@ function registerWsHandlers(ipc) {
3564
3759
  }
3565
3760
  });
3566
3761
  }
3567
- function fetchUrl(url, headers = {}) {
3568
- return new Promise((resolve2, reject) => {
3569
- const lib = url.startsWith("https") ? https : http;
3570
- const req = lib.get(url, { headers }, (res) => {
3571
- const chunks = [];
3572
- res.on("data", (chunk) => chunks.push(chunk));
3573
- res.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
3574
- res.on("error", reject);
3575
- });
3576
- req.on("error", reject);
3577
- req.setTimeout(15e3, () => {
3578
- req.destroy();
3579
- reject(new Error("WSDL fetch timed out"));
3580
- });
3581
- });
3762
+ function isJsonContentType(ct) {
3763
+ return !!ct && /\bjson\b/i.test(ct);
3582
3764
  }
3583
- function parseWsdl(wsdlText) {
3584
- const nsMatch = wsdlText.match(/targetNamespace\s*=\s*["']([^"']+)["']/);
3585
- const targetNamespace = nsMatch ? nsMatch[1] : "";
3586
- const operationRegex = /<(?:wsdl:)?operation\s+name\s*=\s*["']([^"']+)["']/g;
3587
- const operationNames = /* @__PURE__ */ new Set();
3588
- let m;
3589
- while ((m = operationRegex.exec(wsdlText)) !== null) {
3590
- operationNames.add(m[1]);
3591
- }
3592
- const soapActionMap = {};
3593
- const bindingOpRegex = /<(?:wsdl:)?operation\s+name\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/(?:wsdl:)?operation>/g;
3594
- while ((m = bindingOpRegex.exec(wsdlText)) !== null) {
3595
- const opName = m[1];
3596
- const block = m[2];
3597
- const saMatch = block.match(/soapAction\s*=\s*["']([^"']*)["']/);
3598
- if (saMatch) soapActionMap[opName] = saMatch[1];
3599
- }
3600
- const operations = [];
3601
- for (const name of operationNames) {
3602
- const soapAction = soapActionMap[name];
3603
- const inputTemplate = buildEnvelopeTemplate(name, targetNamespace);
3604
- operations.push({ name, soapAction, inputTemplate });
3605
- }
3606
- return { operations, targetNamespace };
3607
- }
3608
- function buildEnvelopeTemplate(operationName, namespace) {
3609
- return `<?xml version="1.0" encoding="utf-8"?>
3610
- <soap:Envelope
3611
- xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
3612
- xmlns:tns="${namespace}">
3613
- <soap:Header/>
3614
- <soap:Body>
3615
- <tns:${operationName}>
3616
- <!-- Add parameters here -->
3617
- </tns:${operationName}>
3618
- </soap:Body>
3619
- </soap:Envelope>`;
3620
- }
3621
- function registerSoapHandlers(ipc) {
3622
- ipc.handle("wsdl:fetch", async (_event, url, extraHeaders = {}) => {
3623
- const wsdlText = await fetchUrl(url, extraHeaders);
3624
- return parseWsdl(wsdlText);
3625
- });
3765
+ function formatBody(body, contentType) {
3766
+ if (!body) return "";
3767
+ const trimmed = body.length > 8e3 ? body.slice(0, 8e3) + "\n… (truncated)" : body;
3768
+ if (isJsonContentType(contentType)) {
3769
+ try {
3770
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
3771
+ } catch {
3772
+ }
3773
+ }
3774
+ return trimmed;
3775
+ }
3776
+ function langForContentType(ct) {
3777
+ if (!ct) return "";
3778
+ if (/\bjson\b/i.test(ct)) return "json";
3779
+ if (/\bxml\b/i.test(ct)) return "xml";
3780
+ if (/\bhtml\b/i.test(ct)) return "html";
3781
+ return "";
3626
3782
  }
3627
3783
  function escMd(s) {
3628
3784
  return s.replace(/[|\\`*_{}[\]()#+\-.!]/g, (c) => `\\${c}`);
3629
3785
  }
3630
- function requestToMarkdown(req) {
3786
+ function requestToMarkdown(req, example) {
3631
3787
  const lines = [];
3632
3788
  const methodLabel = req.protocol === "websocket" ? "WS" : req.method;
3633
3789
  lines.push(`#### ${methodLabel} ${escMd(req.name)}`);
@@ -3694,9 +3850,31 @@ function requestToMarkdown(req) {
3694
3850
  lines.push("```");
3695
3851
  lines.push("");
3696
3852
  }
3853
+ if (example?.sent?.body?.trim()) {
3854
+ const ct = example.sent.headers?.["Content-Type"] ?? example.sent.headers?.["content-type"];
3855
+ const lang = langForContentType(ct);
3856
+ lines.push("**Example Request Body**");
3857
+ lines.push("```" + lang);
3858
+ lines.push(formatBody(example.sent.body, ct));
3859
+ lines.push("```");
3860
+ lines.push("");
3861
+ }
3862
+ if (example?.response) {
3863
+ const ct = example.response.headers["content-type"] ?? example.response.headers["Content-Type"];
3864
+ const lang = langForContentType(ct);
3865
+ lines.push(`**Example Response** (${example.response.status})`);
3866
+ if (example.response.body?.trim()) {
3867
+ lines.push("```" + lang);
3868
+ lines.push(formatBody(example.response.body, ct));
3869
+ lines.push("```");
3870
+ } else {
3871
+ lines.push("_(empty body)_");
3872
+ }
3873
+ lines.push("");
3874
+ }
3697
3875
  return lines.join("\n");
3698
3876
  }
3699
- function folderToMarkdown(folder, requests, depth) {
3877
+ function folderToMarkdown(folder, requests, depth, examples) {
3700
3878
  const lines = [];
3701
3879
  const heading = "#".repeat(depth);
3702
3880
  if (folder.name !== "root") {
@@ -3710,11 +3888,11 @@ function folderToMarkdown(folder, requests, depth) {
3710
3888
  for (const reqId of folder.requestIds) {
3711
3889
  const req = requests[reqId];
3712
3890
  if (req) {
3713
- lines.push(requestToMarkdown(req));
3891
+ lines.push(requestToMarkdown(req, examples[reqId]));
3714
3892
  }
3715
3893
  }
3716
3894
  for (const sub of folder.folders) {
3717
- lines.push(folderToMarkdown(sub, requests, depth + 1));
3895
+ lines.push(folderToMarkdown(sub, requests, depth + 1, examples));
3718
3896
  }
3719
3897
  return lines.join("\n");
3720
3898
  }
@@ -3722,6 +3900,7 @@ function generateMarkdown(payload) {
3722
3900
  const lines = [];
3723
3901
  lines.push("# API Documentation");
3724
3902
  lines.push("");
3903
+ const examples = payload.examples ?? {};
3725
3904
  for (const { collection, requests } of payload.collections) {
3726
3905
  lines.push(`## ${escMd(collection.name)}`);
3727
3906
  lines.push("");
@@ -3729,7 +3908,7 @@ function generateMarkdown(payload) {
3729
3908
  lines.push(collection.description.trim());
3730
3909
  lines.push("");
3731
3910
  }
3732
- lines.push(folderToMarkdown(collection.rootFolder, requests, 3));
3911
+ lines.push(folderToMarkdown(collection.rootFolder, requests, 3, examples));
3733
3912
  }
3734
3913
  return lines.join("\n");
3735
3914
  }
@@ -3746,7 +3925,7 @@ const METHOD_COLORS = {
3746
3925
  OPTIONS: "#9ca3af",
3747
3926
  WS: "#22d3ee"
3748
3927
  };
3749
- function requestToHtml(req) {
3928
+ function requestToHtml(req, example) {
3750
3929
  const methodLabel = req.protocol === "websocket" ? "WS" : req.method;
3751
3930
  const color = METHOD_COLORS[methodLabel] ?? "#9ca3af";
3752
3931
  let html = `<div class="request">`;
@@ -3784,10 +3963,23 @@ function requestToHtml(req) {
3784
3963
  } else if (mode === "soap" && req.body.soap?.envelope?.trim()) {
3785
3964
  html += `<div class="label">Body (SOAP)</div><pre><code>${escHtml(req.body.soap.envelope.trim())}</code></pre>`;
3786
3965
  }
3966
+ if (example?.sent?.body?.trim()) {
3967
+ const ct = example.sent.headers?.["Content-Type"] ?? example.sent.headers?.["content-type"];
3968
+ html += `<div class="label">Example Request Body</div><pre><code class="lang-${escHtml(langForContentType(ct))}">${escHtml(formatBody(example.sent.body, ct))}</code></pre>`;
3969
+ }
3970
+ if (example?.response) {
3971
+ const ct = example.response.headers["content-type"] ?? example.response.headers["Content-Type"];
3972
+ html += `<div class="label">Example Response (${example.response.status})</div>`;
3973
+ if (example.response.body?.trim()) {
3974
+ html += `<pre><code class="lang-${escHtml(langForContentType(ct))}">${escHtml(formatBody(example.response.body, ct))}</code></pre>`;
3975
+ } else {
3976
+ html += `<p class="desc"><em>(empty body)</em></p>`;
3977
+ }
3978
+ }
3787
3979
  html += `</div>`;
3788
3980
  return html;
3789
3981
  }
3790
- function folderToHtml(folder, requests, depth) {
3982
+ function folderToHtml(folder, requests, depth, examples) {
3791
3983
  let html = "";
3792
3984
  const tag = `h${Math.min(depth, 6)}`;
3793
3985
  if (folder.name !== "root") {
@@ -3798,21 +3990,22 @@ function folderToHtml(folder, requests, depth) {
3798
3990
  }
3799
3991
  for (const reqId of folder.requestIds) {
3800
3992
  const req = requests[reqId];
3801
- if (req) html += requestToHtml(req);
3993
+ if (req) html += requestToHtml(req, examples[reqId]);
3802
3994
  }
3803
3995
  for (const sub of folder.folders) {
3804
- html += folderToHtml(sub, requests, depth + 1);
3996
+ html += folderToHtml(sub, requests, depth + 1, examples);
3805
3997
  }
3806
3998
  return html;
3807
3999
  }
3808
4000
  function generateHtml(payload) {
3809
4001
  let body = "";
4002
+ const examples = payload.examples ?? {};
3810
4003
  for (const { collection, requests } of payload.collections) {
3811
4004
  body += `<section class="collection"><h2>${escHtml(collection.name)}</h2>`;
3812
4005
  if (collection.description?.trim()) {
3813
4006
  body += `<p class="collection-desc">${escHtml(collection.description.trim())}</p>`;
3814
4007
  }
3815
- body += folderToHtml(collection.rootFolder, requests, 3);
4008
+ body += folderToHtml(collection.rootFolder, requests, 3, examples);
3816
4009
  body += `</section>`;
3817
4010
  }
3818
4011
  return `<!DOCTYPE html>
@@ -3856,475 +4049,6 @@ function registerDocsHandlers(ipc) {
3856
4049
  return generateMarkdown(payload);
3857
4050
  });
3858
4051
  }
3859
- const ajv$1 = new Ajv({ allErrors: true, strict: false });
3860
- function validateConsumerResponse(contract, actualStatus, actualHeaders, bodyText) {
3861
- const violations = [];
3862
- if (contract.statusCode !== void 0 && actualStatus !== contract.statusCode) {
3863
- violations.push({
3864
- type: "status_mismatch",
3865
- message: `Expected status ${contract.statusCode}, got ${actualStatus}`,
3866
- expected: String(contract.statusCode),
3867
- actual: String(actualStatus)
3868
- });
3869
- }
3870
- for (const expected of contract.headers ?? []) {
3871
- if (!expected.required) continue;
3872
- const actual = actualHeaders[expected.key.toLowerCase()];
3873
- if (actual === void 0) {
3874
- violations.push({
3875
- type: "missing_header",
3876
- message: `Required header "${expected.key}" is absent`,
3877
- expected: expected.value || "(any)",
3878
- actual: "(absent)"
3879
- });
3880
- } else if (expected.value && actual.split(";")[0].trim().toLowerCase() !== expected.value.split(";")[0].trim().toLowerCase()) {
3881
- violations.push({
3882
- type: "missing_header",
3883
- message: `Header "${expected.key}" has unexpected value`,
3884
- expected: expected.value,
3885
- actual
3886
- });
3887
- }
3888
- }
3889
- if (contract.bodySchema?.trim()) {
3890
- let schema;
3891
- try {
3892
- schema = JSON.parse(contract.bodySchema);
3893
- } catch {
3894
- violations.push({
3895
- type: "schema_violation",
3896
- message: "Contract bodySchema is not valid JSON"
3897
- });
3898
- return violations;
3899
- }
3900
- let data;
3901
- try {
3902
- data = JSON.parse(bodyText);
3903
- } catch {
3904
- violations.push({
3905
- type: "schema_violation",
3906
- message: "Response body is not valid JSON — cannot validate against schema"
3907
- });
3908
- return violations;
3909
- }
3910
- try {
3911
- const validate = ajv$1.compile(schema);
3912
- if (!validate(data)) {
3913
- for (const err of validate.errors ?? []) {
3914
- violations.push({
3915
- type: "schema_violation",
3916
- message: err.message ?? "Schema violation",
3917
- path: err.instancePath || "/"
3918
- });
3919
- }
3920
- }
3921
- } catch (e) {
3922
- violations.push({
3923
- type: "schema_violation",
3924
- message: `Schema compile error: ${e instanceof Error ? e.message : String(e)}`
3925
- });
3926
- }
3927
- }
3928
- return violations;
3929
- }
3930
- async function executeContract(req, vars) {
3931
- const url = requestCollection.buildUrl(req.url, req.params, vars);
3932
- const start = Date.now();
3933
- try {
3934
- const headers = new undici.Headers();
3935
- for (const h of req.headers) {
3936
- if (h.enabled && h.key) headers.set(requestCollection.interpolate(h.key, vars), requestCollection.interpolate(h.value, vars));
3937
- }
3938
- const authHeaders = await requestCollection.buildAuthHeaders(req.auth, vars);
3939
- for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
3940
- let body;
3941
- if (req.body.mode === "json" && req.body.json) {
3942
- body = requestCollection.interpolate(req.body.json, vars);
3943
- if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
3944
- } else if (req.body.mode === "raw" && req.body.raw) {
3945
- body = requestCollection.interpolate(req.body.raw, vars);
3946
- }
3947
- const resp = await undici.fetch(url, {
3948
- method: req.method,
3949
- headers,
3950
- body: !["GET", "HEAD"].includes(req.method) ? body : void 0
3951
- });
3952
- const bodyText = await resp.text();
3953
- const rawHeaders = {};
3954
- resp.headers.forEach((v, k) => {
3955
- rawHeaders[k] = v;
3956
- });
3957
- const violations = validateConsumerResponse(req.contract, resp.status, rawHeaders, bodyText);
3958
- return {
3959
- requestId: req.id,
3960
- requestName: req.name,
3961
- method: req.method,
3962
- url,
3963
- passed: violations.length === 0,
3964
- violations,
3965
- durationMs: Date.now() - start,
3966
- actualStatus: resp.status
3967
- };
3968
- } catch (err) {
3969
- return {
3970
- requestId: req.id,
3971
- requestName: req.name,
3972
- method: req.method,
3973
- url,
3974
- passed: false,
3975
- violations: [{
3976
- type: "status_mismatch",
3977
- message: `Request failed: ${err instanceof Error ? err.message : String(err)}`
3978
- }],
3979
- durationMs: Date.now() - start
3980
- };
3981
- }
3982
- }
3983
- async function runConsumerContracts(requests, envVars, collectionVars = {}) {
3984
- const vars = { ...envVars, ...collectionVars };
3985
- const contractRequests = requests.filter(
3986
- (r) => !r.disabled && r.contract && (r.contract.statusCode !== void 0 || r.contract.bodySchema || r.contract.headers?.length)
3987
- );
3988
- const start = Date.now();
3989
- const results = await Promise.all(contractRequests.map((r) => executeContract(r, vars)));
3990
- const passed = results.filter((r) => r.passed).length;
3991
- return {
3992
- mode: "consumer",
3993
- total: results.length,
3994
- passed,
3995
- failed: results.length - passed,
3996
- results,
3997
- durationMs: Date.now() - start
3998
- };
3999
- }
4000
- const ajv = new Ajv({ allErrors: true, strict: false });
4001
- async function loadSpec(specUrl, specPath) {
4002
- if (specUrl) {
4003
- const resp = await undici.fetch(specUrl);
4004
- if (!resp.ok) throw new Error(`HTTP ${resp.status} loading spec from ${specUrl}`);
4005
- const text = await resp.text();
4006
- const ct = resp.headers.get("content-type") ?? "";
4007
- return ct.includes("yaml") || specUrl.endsWith(".yaml") || specUrl.endsWith(".yml") ? jsYaml.load(text) : JSON.parse(text);
4008
- }
4009
- if (specPath) {
4010
- const raw = await promises.readFile(specPath, "utf8");
4011
- return specPath.endsWith(".yaml") || specPath.endsWith(".yml") ? jsYaml.load(raw) : JSON.parse(raw);
4012
- }
4013
- throw new Error("Either specUrl or specPath must be provided");
4014
- }
4015
- function resolveRef(spec, ref) {
4016
- const parts = ref.replace(/^#\//, "").split("/");
4017
- return parts.reduce((obj, key) => obj?.[key], spec);
4018
- }
4019
- function resolveSchema(spec, obj, seen = /* @__PURE__ */ new Set()) {
4020
- if (!obj || typeof obj !== "object") return obj;
4021
- if (seen.has(obj)) return {};
4022
- if (Array.isArray(obj)) {
4023
- seen.add(obj);
4024
- return obj.map((i) => resolveSchema(spec, i, seen));
4025
- }
4026
- const o = obj;
4027
- if ("$ref" in o) {
4028
- const target = resolveRef(spec, o["$ref"]);
4029
- return resolveSchema(spec, target, seen);
4030
- }
4031
- seen.add(obj);
4032
- return Object.fromEntries(Object.entries(o).map(([k, v]) => [k, resolveSchema(spec, v, seen)]));
4033
- }
4034
- function getServerBases(spec) {
4035
- const servers = spec["servers"] ?? [];
4036
- if (!servers.length) return [""];
4037
- return servers.map((s) => {
4038
- const raw = s.url ?? "";
4039
- try {
4040
- return new URL(raw).pathname.replace(/\/$/, "");
4041
- } catch {
4042
- return raw.replace(/\/$/, "");
4043
- }
4044
- });
4045
- }
4046
- function urlPathname(raw, baseUrl) {
4047
- try {
4048
- const pathname = new URL(raw).pathname;
4049
- if (baseUrl) {
4050
- try {
4051
- const basePath = new URL(baseUrl).pathname.replace(/\/$/, "");
4052
- if (basePath && pathname.startsWith(basePath)) return pathname.slice(basePath.length) || "/";
4053
- } catch {
4054
- }
4055
- }
4056
- return pathname;
4057
- } catch {
4058
- return raw.split("?")[0];
4059
- }
4060
- }
4061
- function pathTemplateToRegex(base, template) {
4062
- const combined = (base + template).replace(/\/+/g, "/");
4063
- const pattern = combined.replace(/\{[^}]+\}/g, "[^/]+");
4064
- return new RegExp("^" + pattern + "/?$");
4065
- }
4066
- function findOperation(spec, method, reqUrl, requestBaseUrl) {
4067
- const bases = getServerBases(spec);
4068
- const pathname = urlPathname(reqUrl, requestBaseUrl);
4069
- const paths = spec["paths"] ?? {};
4070
- for (const [template, pathItem] of Object.entries(paths)) {
4071
- const resolved = resolveSchema(spec, pathItem);
4072
- for (const base of bases) {
4073
- if (pathTemplateToRegex(base, template).test(pathname)) {
4074
- const op = resolved[method.toLowerCase()];
4075
- return op ? { pathTemplate: template, operation: op } : null;
4076
- }
4077
- }
4078
- }
4079
- return null;
4080
- }
4081
- function validateRequestAgainstSpec(spec, req, envVars, requestBaseUrl) {
4082
- const violations = [];
4083
- const vars = envVars;
4084
- const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
4085
- const match = findOperation(spec, req.method, url, requestBaseUrl);
4086
- if (!match) {
4087
- violations.push({
4088
- type: "unknown_path",
4089
- message: `No operation found in spec for ${req.method} ${url}`
4090
- });
4091
- return violations;
4092
- }
4093
- const { operation } = match;
4094
- if (req.body.mode === "json" && req.body.json?.trim()) {
4095
- const requestBody = resolveSchema(spec, operation["requestBody"]);
4096
- const content = requestBody?.["content"] ?? {};
4097
- const jsonContent = content["application/json"];
4098
- if (jsonContent?.["schema"]) {
4099
- try {
4100
- const data = JSON.parse(requestCollection.interpolate(req.body.json, vars));
4101
- const schema = resolveSchema(spec, jsonContent["schema"]);
4102
- const validate = ajv.compile(schema);
4103
- if (!validate(data)) {
4104
- for (const err of validate.errors ?? []) {
4105
- violations.push({
4106
- type: "request_body_invalid",
4107
- message: err.message ?? "Request body schema violation",
4108
- path: err.instancePath || "/"
4109
- });
4110
- }
4111
- }
4112
- } catch (e) {
4113
- violations.push({
4114
- type: "request_body_invalid",
4115
- message: `Could not validate request body: ${e instanceof Error ? e.message : String(e)}`
4116
- });
4117
- }
4118
- }
4119
- }
4120
- const parameters = resolveSchema(spec, operation["parameters"] ?? []);
4121
- for (const param of parameters) {
4122
- const p = param;
4123
- if (p["in"] === "query" && p["required"] === true) {
4124
- const name = p["name"];
4125
- if (!req.params.some((kv) => kv.enabled && kv.key === name)) {
4126
- violations.push({
4127
- type: "request_body_invalid",
4128
- message: `Required query parameter "${name}" is missing`,
4129
- path: `query.${name}`
4130
- });
4131
- }
4132
- }
4133
- }
4134
- return violations;
4135
- }
4136
- async function runProviderVerification(requests, envVars, specUrl, specPath, requestBaseUrl) {
4137
- const spec = await loadSpec(specUrl, specPath);
4138
- const start = Date.now();
4139
- const activeRequests = requests.filter((r) => !r.disabled);
4140
- const results = activeRequests.map((req) => {
4141
- const violations = validateRequestAgainstSpec(spec, req, envVars, requestBaseUrl);
4142
- const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => envVars[k] ?? `{{${k}}}`);
4143
- return {
4144
- requestId: req.id,
4145
- requestName: req.name,
4146
- method: req.method,
4147
- url,
4148
- passed: violations.length === 0,
4149
- violations
4150
- };
4151
- });
4152
- const passed = results.filter((r) => r.passed).length;
4153
- return {
4154
- mode: "provider",
4155
- total: results.length,
4156
- passed,
4157
- failed: results.length - passed,
4158
- results,
4159
- durationMs: Date.now() - start
4160
- };
4161
- }
4162
- function checkSchemaCompatibility(consumerSchema, providerSchema, path2 = "") {
4163
- const violations = [];
4164
- if (!consumerSchema || !providerSchema) return violations;
4165
- const cType = consumerSchema["type"];
4166
- const pType = providerSchema["type"];
4167
- if (cType && pType && cType !== pType) {
4168
- const ok = cType === "integer" && pType === "number" || cType === "number" && pType === "integer";
4169
- if (!ok) {
4170
- violations.push({
4171
- type: "schema_incompatible",
4172
- message: `Type mismatch${path2 ? ` at "${path2}"` : ""}: consumer expects "${cType}", provider offers "${pType}"`,
4173
- path: path2 || "/",
4174
- expected: cType,
4175
- actual: pType
4176
- });
4177
- return violations;
4178
- }
4179
- }
4180
- if (cType === "array" || Array.isArray(consumerSchema["items"])) {
4181
- const cItems = consumerSchema["items"];
4182
- const pItems = providerSchema["items"];
4183
- if (cItems && pItems) {
4184
- violations.push(...checkSchemaCompatibility(cItems, pItems, path2 ? `${path2}[]` : "[]"));
4185
- }
4186
- return violations;
4187
- }
4188
- if (cType === "object" || consumerSchema["properties"]) {
4189
- const cProps = consumerSchema["properties"] ?? {};
4190
- const pProps = providerSchema["properties"] ?? {};
4191
- const cRequired = consumerSchema["required"] ?? [];
4192
- for (const field of cRequired) {
4193
- const fieldPath = path2 ? `${path2}.${field}` : field;
4194
- if (!(field in pProps)) {
4195
- violations.push({
4196
- type: "schema_incompatible",
4197
- message: `Consumer requires field "${fieldPath}" which is not defined in provider schema`,
4198
- path: fieldPath,
4199
- expected: "(defined)",
4200
- actual: "(absent)"
4201
- });
4202
- }
4203
- }
4204
- for (const [field, cPropSchema] of Object.entries(cProps)) {
4205
- if (field in pProps) {
4206
- const fieldPath = path2 ? `${path2}.${field}` : field;
4207
- violations.push(...checkSchemaCompatibility(
4208
- cPropSchema,
4209
- pProps[field],
4210
- fieldPath
4211
- ));
4212
- }
4213
- }
4214
- }
4215
- return violations;
4216
- }
4217
- function getProviderResponseSchema(spec, req, envVars, statusCode, requestBaseUrl) {
4218
- const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => envVars[k] ?? `{{${k}}}`);
4219
- const match = findOperation(spec, req.method, url, requestBaseUrl);
4220
- if (!match) return null;
4221
- const responses = match.operation["responses"] ?? {};
4222
- const candidates = [String(statusCode), `${String(statusCode)[0]}xx`, "2XX", "2xx", "default"];
4223
- for (const candidate of candidates) {
4224
- const resp = responses[candidate];
4225
- if (resp) {
4226
- const resolved = resolveSchema(spec, resp);
4227
- const content = resolved["content"] ?? {};
4228
- const json = content["application/json"];
4229
- if (json?.["schema"]) {
4230
- return resolveSchema(spec, json["schema"]);
4231
- }
4232
- }
4233
- }
4234
- return null;
4235
- }
4236
- async function executeRequest(req, vars) {
4237
- const url = requestCollection.buildUrl(req.url, req.params, vars);
4238
- const start = Date.now();
4239
- try {
4240
- const headers = new undici.Headers();
4241
- for (const h of req.headers) {
4242
- if (h.enabled && h.key) headers.set(requestCollection.interpolate(h.key, vars), requestCollection.interpolate(h.value, vars));
4243
- }
4244
- const authHeaders = await requestCollection.buildAuthHeaders(req.auth, vars);
4245
- for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
4246
- let body;
4247
- if (req.body.mode === "json" && req.body.json) {
4248
- body = requestCollection.interpolate(req.body.json, vars);
4249
- if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
4250
- }
4251
- const resp = await undici.fetch(url, {
4252
- method: req.method,
4253
- headers,
4254
- body: !["GET", "HEAD"].includes(req.method) ? body : void 0
4255
- });
4256
- const bodyText = await resp.text();
4257
- const rawHdrs = {};
4258
- resp.headers.forEach((v, k) => {
4259
- rawHdrs[k] = v;
4260
- });
4261
- return { status: resp.status, headers: rawHdrs, body: bodyText, durationMs: Date.now() - start };
4262
- } catch (err) {
4263
- return err instanceof Error ? err : new Error(String(err));
4264
- }
4265
- }
4266
- async function runBidirectional(requests, envVars, collectionVars = {}, specUrl, specPath, requestBaseUrl) {
4267
- const spec = await loadSpec(specUrl, specPath);
4268
- const vars = { ...envVars, ...collectionVars };
4269
- const start = Date.now();
4270
- const contractRequests = requests.filter(
4271
- (r) => !r.disabled && r.contract && (r.contract.statusCode !== void 0 || r.contract.bodySchema || r.contract.headers?.length)
4272
- );
4273
- const results = await Promise.all(contractRequests.map(async (req) => {
4274
- const url = requestCollection.buildUrl(req.url, req.params, vars);
4275
- const violations = [];
4276
- const expectedStatus = req.contract.statusCode ?? 200;
4277
- const consumerSchema = req.contract.bodySchema ? (() => {
4278
- try {
4279
- return JSON.parse(req.contract.bodySchema);
4280
- } catch {
4281
- return null;
4282
- }
4283
- })() : null;
4284
- if (consumerSchema) {
4285
- const providerSchema = getProviderResponseSchema(spec, req, vars, expectedStatus, requestBaseUrl);
4286
- if (!providerSchema) {
4287
- violations.push({
4288
- type: "schema_incompatible",
4289
- message: `No response schema found in spec for ${req.method} ${url} → ${expectedStatus}`
4290
- });
4291
- } else {
4292
- violations.push(...checkSchemaCompatibility(consumerSchema, providerSchema));
4293
- }
4294
- }
4295
- const result = await executeRequest(req, vars);
4296
- if (result instanceof Error) {
4297
- violations.push({ type: "status_mismatch", message: `Request failed: ${result.message}` });
4298
- return { requestId: req.id, requestName: req.name, method: req.method, url, passed: false, violations };
4299
- }
4300
- const liveViolations = validateConsumerResponse(
4301
- req.contract,
4302
- result.status,
4303
- result.headers,
4304
- result.body
4305
- );
4306
- violations.push(...liveViolations);
4307
- return {
4308
- requestId: req.id,
4309
- requestName: req.name,
4310
- method: req.method,
4311
- url,
4312
- passed: violations.length === 0,
4313
- violations,
4314
- durationMs: result.durationMs,
4315
- actualStatus: result.status
4316
- };
4317
- }));
4318
- const passed = results.filter((r) => r.passed).length;
4319
- return {
4320
- mode: "bidirectional",
4321
- total: results.length,
4322
- passed,
4323
- failed: results.length - passed,
4324
- results,
4325
- durationMs: Date.now() - start
4326
- };
4327
- }
4328
4052
  function inferSchema(data) {
4329
4053
  if (data === null || data === void 0) return { type: "null" };
4330
4054
  if (typeof data === "boolean") return { type: "boolean" };
@@ -4357,22 +4081,60 @@ function inferSchemaFromJson(json) {
4357
4081
  return null;
4358
4082
  }
4359
4083
  }
4084
+ async function resolveSnapshotSpec(relPath) {
4085
+ const dir = getWorkspaceDir();
4086
+ if (!dir) throw new Error("No workspace open — cannot resolve snapshot.");
4087
+ const snap = await snapshots.loadSnapshot(dir, relPath);
4088
+ const tmp = path.join(os.tmpdir(), `api-spector-${crypto.randomUUID()}.${snap.format === "yaml" ? "yaml" : "json"}`);
4089
+ await promises.writeFile(tmp, snap.spec, "utf8");
4090
+ return { specPath: tmp };
4091
+ }
4360
4092
  function registerContractHandlers(ipc) {
4361
4093
  ipc.handle("contract:run", async (_e, payload) => {
4362
- const { mode, requests, envVars, collectionVars = {}, specUrl, specPath, requestBaseUrl } = payload;
4094
+ ipcValidate.validateContractRunPayload(payload);
4095
+ const { mode, requests, envVars, collectionVars = {}, requestBaseUrl } = payload;
4096
+ let { specUrl, specPath } = payload;
4097
+ if (payload.specSnapshotRelPath) {
4098
+ const resolved = await resolveSnapshotSpec(payload.specSnapshotRelPath);
4099
+ specPath = resolved.specPath;
4100
+ specUrl = void 0;
4101
+ }
4363
4102
  switch (mode) {
4364
4103
  case "consumer":
4365
- return runConsumerContracts(requests, envVars, collectionVars);
4104
+ return snapshots.runConsumerContracts(requests, envVars, collectionVars);
4366
4105
  case "provider":
4367
- return runProviderVerification(requests, envVars, specUrl, specPath, requestBaseUrl);
4106
+ return snapshots.runProviderVerification(requests, envVars, specUrl, specPath, requestBaseUrl);
4368
4107
  case "bidirectional":
4369
- return runBidirectional(requests, envVars, collectionVars, specUrl, specPath, requestBaseUrl);
4108
+ return snapshots.runBidirectional(requests, envVars, collectionVars, specUrl, specPath, requestBaseUrl);
4370
4109
  }
4371
4110
  });
4372
4111
  ipc.handle("contract:inferSchema", (_e, jsonBody) => {
4373
4112
  const schema = inferSchemaFromJson(jsonBody);
4374
4113
  return schema ? JSON.stringify(schema, null, 2) : null;
4375
4114
  });
4115
+ ipc.handle("contract:captureSnapshot", async (_e, opts) => {
4116
+ const dir = getWorkspaceDir();
4117
+ if (!dir) throw new Error("No workspace open — cannot capture snapshot.");
4118
+ const snapshot = await snapshots.captureSnapshot(dir, opts);
4119
+ const relPath = snapshots.relPathOf(snapshot);
4120
+ if (!relPath) throw new Error("Snapshot created but relPath was not attached.");
4121
+ return { relPath, snapshot };
4122
+ });
4123
+ ipc.handle("contract:listSnapshots", async (_e, registered = []) => {
4124
+ const dir = getWorkspaceDir();
4125
+ if (!dir) return [];
4126
+ return snapshots.listSnapshots(dir, registered);
4127
+ });
4128
+ ipc.handle("contract:loadSnapshot", async (_e, relPath) => {
4129
+ const dir = getWorkspaceDir();
4130
+ if (!dir) throw new Error("No workspace open.");
4131
+ return snapshots.loadSnapshot(dir, relPath);
4132
+ });
4133
+ ipc.handle("contract:deleteSnapshot", async (_e, relPath) => {
4134
+ const dir = getWorkspaceDir();
4135
+ if (!dir) return;
4136
+ await snapshots.deleteSnapshot(dir, relPath);
4137
+ });
4376
4138
  }
4377
4139
  function git() {
4378
4140
  const dir = getWorkspaceDir();
@@ -4390,6 +4152,8 @@ function registerGitHandlers(ipc) {
4390
4152
  });
4391
4153
  ipc.handle("git:init", async () => {
4392
4154
  await git().init();
4155
+ const dir = getWorkspaceDir();
4156
+ if (dir) await ensureGitignore(dir);
4393
4157
  });
4394
4158
  ipc.handle("git:status", async () => {
4395
4159
  const result = await git().status();
@@ -4449,16 +4213,58 @@ function registerGitHandlers(ipc) {
4449
4213
  }));
4450
4214
  });
4451
4215
  ipc.handle("git:branches", async () => {
4452
- const result = await git().branch(["-a", "--format=%(refname:short)|%(objectname:short)|%(upstream:short)|%(upstream:track)"]);
4453
- return result.all.filter((name) => !name.includes("HEAD")).map((name) => ({
4454
- name: name.replace(/^remotes\//, ""),
4455
- current: name === result.current,
4456
- remote: name.startsWith("remotes/")
4457
- }));
4216
+ const raw = await git().raw([
4217
+ "for-each-ref",
4218
+ "--format=%(refname:short)|%(HEAD)|%(upstream:short)|%(upstream:track)",
4219
+ "refs/heads",
4220
+ "refs/remotes"
4221
+ ]);
4222
+ const current = (await git().branch()).current;
4223
+ const branches = [];
4224
+ for (const line of raw.split("\n")) {
4225
+ if (!line.trim()) continue;
4226
+ const [shortName, head, upstream, track] = line.split("|");
4227
+ if (!shortName || shortName.endsWith("/HEAD")) continue;
4228
+ const isRemote = shortName.startsWith("origin/") || shortName.includes("/");
4229
+ const looksLocal = !isRemote;
4230
+ let ahead;
4231
+ let behind;
4232
+ const aheadM = track?.match(/ahead (\d+)/);
4233
+ const behindM = track?.match(/behind (\d+)/);
4234
+ if (aheadM) ahead = Number(aheadM[1]);
4235
+ if (behindM) behind = Number(behindM[1]);
4236
+ branches.push({
4237
+ name: shortName,
4238
+ current: looksLocal && (head === "*" || shortName === current),
4239
+ remote: isRemote,
4240
+ upstream: upstream || void 0,
4241
+ ahead,
4242
+ behind
4243
+ });
4244
+ }
4245
+ return branches;
4458
4246
  });
4459
4247
  ipc.handle("git:checkout", async (_e, branch, create) => {
4460
- if (create) await git().checkoutLocalBranch(branch);
4461
- else await git().checkout(branch);
4248
+ if (create) {
4249
+ await git().checkoutLocalBranch(branch);
4250
+ return;
4251
+ }
4252
+ const m = /^([^/]+)\/(.+)$/.exec(branch);
4253
+ if (m) {
4254
+ const remote = m[1];
4255
+ const localName = m[2];
4256
+ const localList = await git().branchLocal();
4257
+ if (!localList.all.includes(localName)) {
4258
+ await git().checkoutBranch(localName, `${remote}/${localName}`);
4259
+ return;
4260
+ }
4261
+ await git().checkout(localName);
4262
+ return;
4263
+ }
4264
+ await git().checkout(branch);
4265
+ });
4266
+ ipc.handle("git:deleteBranch", async (_e, name, force = false) => {
4267
+ await git().deleteLocalBranch(name, force);
4462
4268
  });
4463
4269
  ipc.handle("git:pull", async () => {
4464
4270
  await git().pull();
@@ -4608,17 +4414,17 @@ function createWindow() {
4608
4414
  }
4609
4415
  electron.app.whenReady().then(async () => {
4610
4416
  if (process.platform !== "darwin") electron.Menu.setApplicationMenu(null);
4611
- await requestCollection.initSecretStore(electron.app.getPath("userData"));
4417
+ await authBuilder.initSecretStore(electron.app.getPath("userData"));
4612
4418
  registerFileHandlers(electron.ipcMain);
4613
4419
  requestCollection.registerRequestHandler(electron.ipcMain);
4614
- requestCollection.registerSecretHandlers(electron.ipcMain);
4420
+ authBuilder.registerSecretHandlers(electron.ipcMain);
4615
4421
  registerImportHandlers(electron.ipcMain);
4616
4422
  registerGenerateHandlers(electron.ipcMain);
4617
4423
  registerRunnerHandler(electron.ipcMain);
4618
4424
  registerMockHandlers(electron.ipcMain);
4619
4425
  registerOAuth2Handlers(electron.ipcMain);
4620
4426
  registerWsHandlers(electron.ipcMain);
4621
- registerSoapHandlers(electron.ipcMain);
4427
+ soapHandler.registerSoapHandlers(electron.ipcMain);
4622
4428
  registerDocsHandlers(electron.ipcMain);
4623
4429
  registerContractHandlers(electron.ipcMain);
4624
4430
  registerGitHandlers(electron.ipcMain);