@testsmith/api-spector 0.2.2 → 0.2.3

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,7 +25,8 @@ 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-DMVlm0PA.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");
@@ -34,15 +35,17 @@ const mockServer = require("./chunks/mock-server-Cx-xG4pJ.js");
34
35
  const http = require("http");
35
36
  const WebSocket = require("ws");
36
37
  const https = require("https");
37
- const Ajv = require("ajv");
38
+ const os = require("os");
39
+ const crypto = require("crypto");
40
+ const snapshots = require("./chunks/snapshots-C7YbGHM7.js");
38
41
  const simpleGit = require("simple-git");
39
42
  const recorder = require("./chunks/recorder-DFxJgn9c.js");
40
- require("crypto");
41
- require("dayjs");
42
43
  require("vm");
44
+ require("dayjs");
43
45
  require("tv4");
44
46
  require("jsonpath-plus");
45
47
  require("@xmldom/xmldom");
48
+ require("ajv");
46
49
  const LAST_WS_FILE = path.join(electron.app.getPath("userData"), "last-workspace.json");
47
50
  async function saveLastWorkspacePath(wsPath) {
48
51
  await promises.writeFile(LAST_WS_FILE, JSON.stringify({ path: wsPath }), "utf8");
@@ -399,7 +402,7 @@ async function importPostman(filePath) {
399
402
  requests
400
403
  };
401
404
  }
402
- async function loadSpec$1(filePath) {
405
+ async function loadSpec(filePath) {
403
406
  const raw = await promises.readFile(filePath, "utf8");
404
407
  if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {
405
408
  return jsYaml.load(raw);
@@ -416,7 +419,7 @@ async function loadSpecFromUrl(url) {
416
419
  }
417
420
  return JSON.parse(text);
418
421
  }
419
- function resolveRef$1(spec, ref) {
422
+ function resolveRef(spec, ref) {
420
423
  const parts = ref.replace(/^#\//, "").split("/");
421
424
  return parts.reduce((obj, key) => obj?.[decodeURIComponent(key.replace(/~1/g, "/").replace(/~0/g, "~"))], spec);
422
425
  }
@@ -428,7 +431,7 @@ function resolve(spec, obj, seen = /* @__PURE__ */ new Set()) {
428
431
  return obj.map((item) => resolve(spec, item, seen));
429
432
  }
430
433
  if ("$ref" in obj) {
431
- const target = resolveRef$1(spec, obj.$ref);
434
+ const target = resolveRef(spec, obj.$ref);
432
435
  if (!target || seen.has(target)) return {};
433
436
  return resolve(spec, target, seen);
434
437
  }
@@ -626,7 +629,7 @@ function buildCollection(spec) {
626
629
  };
627
630
  }
628
631
  async function importOpenApi(filePath) {
629
- return buildCollection(await loadSpec$1(filePath));
632
+ return buildCollection(await loadSpec(filePath));
630
633
  }
631
634
  async function importOpenApiFromUrl(url) {
632
635
  return buildCollection(await loadSpecFromUrl(url));
@@ -652,7 +655,7 @@ function extractSchemas(spec) {
652
655
  return entries;
653
656
  }
654
657
  async function extractSchemasFromFile(filePath) {
655
- return extractSchemas(await loadSpec$1(filePath));
658
+ return extractSchemas(await loadSpec(filePath));
656
659
  }
657
660
  async function extractSchemasFromUrl(url) {
658
661
  return extractSchemas(await loadSpecFromUrl(url));
@@ -3086,14 +3089,14 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3086
3089
  resolvedUrl: "",
3087
3090
  status: "running"
3088
3091
  };
3089
- const dynamicVars = await requestCollection.buildDynamicVars();
3090
- let vars = requestCollection.mergeVars(envVars, collectionVars, globals, localVars, dynamicVars);
3092
+ const dynamicVars = await authBuilder.buildDynamicVars();
3093
+ let vars = authBuilder.mergeVars(envVars, collectionVars, globals, localVars, dynamicVars);
3091
3094
  let updatedEnvVars = { ...envVars };
3092
3095
  let updatedCollectionVars = { ...collectionVars };
3093
3096
  let updatedGlobals = { ...globals };
3094
3097
  let preScriptError;
3095
3098
  if (req.preRequestScript?.trim()) {
3096
- const r = await requestCollection.runScript(requestCollection.interpolate(req.preRequestScript, vars), {
3099
+ const r = await requestCollection.runScript(authBuilder.interpolate(req.preRequestScript, vars), {
3097
3100
  envVars: { ...envVars },
3098
3101
  collectionVars: { ...collectionVars },
3099
3102
  globals: { ...globals },
@@ -3106,9 +3109,9 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3106
3109
  updatedGlobals = r.updatedGlobals;
3107
3110
  requestCollection.patchGlobals(r.updatedGlobals);
3108
3111
  await requestCollection.persistGlobals();
3109
- vars = requestCollection.mergeVars(updatedEnvVars, updatedCollectionVars, updatedGlobals, localVars, dynamicVars);
3112
+ vars = authBuilder.mergeVars(updatedEnvVars, updatedCollectionVars, updatedGlobals, localVars, dynamicVars);
3110
3113
  }
3111
- const resolvedUrl = requestCollection.buildUrl(req.url, req.params, vars);
3114
+ const resolvedUrl = authBuilder.buildUrl(req.url, req.params, vars);
3112
3115
  base.resolvedUrl = resolvedUrl;
3113
3116
  const start = Date.now();
3114
3117
  try {
@@ -3117,23 +3120,23 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3117
3120
  const tokenMissing = !req.auth.oauth2CachedToken;
3118
3121
  const tokenExpired = req.auth.oauth2TokenExpiry ? req.auth.oauth2TokenExpiry <= now + 5e3 : true;
3119
3122
  if (tokenMissing || tokenExpired) {
3120
- const result = await requestCollection.fetchOAuth2Token(req.auth, vars);
3123
+ const result = await authBuilder.fetchOAuth2Token(req.auth, vars);
3121
3124
  req.auth.oauth2CachedToken = result.accessToken;
3122
3125
  req.auth.oauth2TokenExpiry = result.expiresAt;
3123
3126
  }
3124
3127
  }
3125
- const authHeaders = await requestCollection.buildAuthHeaders(req.auth, vars);
3128
+ const authHeaders = await authBuilder.buildAuthHeaders(req.auth, vars);
3126
3129
  const headers = new undici.Headers();
3127
3130
  for (const h of req.headers) {
3128
- if (h.enabled && h.key) headers.set(requestCollection.interpolate(h.key, vars), requestCollection.interpolate(h.value, vars));
3131
+ if (h.enabled && h.key) headers.set(authBuilder.interpolate(h.key, vars), authBuilder.interpolate(h.value, vars));
3129
3132
  }
3130
3133
  for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
3131
3134
  let body;
3132
3135
  if (req.body.mode === "json" && req.body.json) {
3133
- body = requestCollection.interpolate(req.body.json, vars);
3136
+ body = authBuilder.interpolate(req.body.json, vars);
3134
3137
  if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
3135
3138
  } else if (req.body.mode === "raw" && req.body.raw) {
3136
- body = requestCollection.interpolate(req.body.raw, vars);
3139
+ body = authBuilder.interpolate(req.body.raw, vars);
3137
3140
  if (!headers.has("content-type")) headers.set("Content-Type", req.body.rawContentType ?? "text/plain");
3138
3141
  }
3139
3142
  const methodHasBody = !["GET", "HEAD"].includes(req.method);
@@ -3145,14 +3148,14 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3145
3148
  });
3146
3149
  let fetchResp;
3147
3150
  if (req.auth.type === "ntlm") {
3148
- await requestCollection.performNtlmRequest(resolvedUrl, req.method, req.auth, vars);
3151
+ await authBuilder.performNtlmRequest(resolvedUrl, req.method, req.auth, vars);
3149
3152
  fetchResp = await doFetch(headers);
3150
3153
  } else if (req.auth.type === "digest") {
3151
3154
  const probeFetch = (url, init) => undici.fetch(url, {
3152
3155
  ...init,
3153
3156
  dispatcher
3154
3157
  });
3155
- const digestHeader = await requestCollection.performDigestAuth(resolvedUrl, req.method, req.auth, vars, probeFetch);
3158
+ const digestHeader = await authBuilder.performDigestAuth(resolvedUrl, req.method, req.auth, vars, probeFetch);
3156
3159
  if (digestHeader) headers.set("Authorization", digestHeader);
3157
3160
  fetchResp = await doFetch(headers);
3158
3161
  } else {
@@ -3179,7 +3182,7 @@ async function executeOne(req, collectionVars, envVars, globals, localVars, disp
3179
3182
  let consoleOutput = [];
3180
3183
  let postScriptError;
3181
3184
  if (req.postRequestScript?.trim()) {
3182
- const r = await requestCollection.runScript(requestCollection.interpolate(req.postRequestScript, vars), {
3185
+ const r = await requestCollection.runScript(authBuilder.interpolate(req.postRequestScript, vars), {
3183
3186
  envVars: updatedEnvVars,
3184
3187
  collectionVars: updatedCollectionVars,
3185
3188
  globals: updatedGlobals,
@@ -3257,7 +3260,7 @@ const sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
3257
3260
  function registerRunnerHandler(ipc) {
3258
3261
  ipc.handle("runner:start", async (event, payload) => {
3259
3262
  const { items, environment, globals: payloadGlobals, proxy, tls, piiMaskPatterns = [], requestDelay = 0 } = payload;
3260
- const envVars = await requestCollection.buildEnvVars(environment);
3263
+ const envVars = await authBuilder.buildEnvVars(environment);
3261
3264
  const liveGlobals = requestCollection.getGlobals();
3262
3265
  const globals = { ...payloadGlobals, ...liveGlobals };
3263
3266
  const dispatcher = await buildDispatcher(proxy, tls);
@@ -3392,14 +3395,14 @@ function registerOAuth2Handlers(ipc) {
3392
3395
  ipc.handle("oauth2:startFlow", async (_e, auth, vars) => {
3393
3396
  const port = auth.oauth2RedirectPort ?? 9876;
3394
3397
  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);
3398
+ const authUrl = authBuilder.interpolate(auth.oauth2AuthUrl ?? "", vars);
3399
+ const tokenUrl = authBuilder.interpolate(auth.oauth2TokenUrl ?? "", vars);
3400
+ const clientId = authBuilder.interpolate(auth.oauth2ClientId ?? "", vars);
3398
3401
  let clientSecret = auth.oauth2ClientSecret ?? "";
3399
3402
  if (!clientSecret && auth.oauth2ClientSecretRef) {
3400
- clientSecret = await requestCollection.getSecret(auth.oauth2ClientSecretRef) ?? "";
3403
+ clientSecret = await authBuilder.getSecret(auth.oauth2ClientSecretRef) ?? "";
3401
3404
  }
3402
- clientSecret = requestCollection.interpolate(clientSecret, vars);
3405
+ clientSecret = authBuilder.interpolate(clientSecret, vars);
3403
3406
  if (!authUrl) throw new Error("OAuth 2.0: authUrl is required for authorization_code flow.");
3404
3407
  if (!tokenUrl) throw new Error("OAuth 2.0: tokenUrl is required for authorization_code flow.");
3405
3408
  if (!clientId) throw new Error("OAuth 2.0: clientId is required.");
@@ -3474,13 +3477,13 @@ function registerOAuth2Handlers(ipc) {
3474
3477
  };
3475
3478
  });
3476
3479
  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);
3480
+ const tokenUrl = authBuilder.interpolate(auth.oauth2TokenUrl ?? "", vars);
3481
+ const clientId = authBuilder.interpolate(auth.oauth2ClientId ?? "", vars);
3479
3482
  let clientSecret = auth.oauth2ClientSecret ?? "";
3480
3483
  if (!clientSecret && auth.oauth2ClientSecretRef) {
3481
- clientSecret = await requestCollection.getSecret(auth.oauth2ClientSecretRef) ?? "";
3484
+ clientSecret = await authBuilder.getSecret(auth.oauth2ClientSecretRef) ?? "";
3482
3485
  }
3483
- clientSecret = requestCollection.interpolate(clientSecret, vars);
3486
+ clientSecret = authBuilder.interpolate(clientSecret, vars);
3484
3487
  if (!tokenUrl) throw new Error("OAuth 2.0: tokenUrl is required for refresh.");
3485
3488
  const { fetch: nodeFetch } = await import("undici");
3486
3489
  const params = new URLSearchParams();
@@ -3856,475 +3859,6 @@ function registerDocsHandlers(ipc) {
3856
3859
  return generateMarkdown(payload);
3857
3860
  });
3858
3861
  }
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
3862
  function inferSchema(data) {
4329
3863
  if (data === null || data === void 0) return { type: "null" };
4330
3864
  if (typeof data === "boolean") return { type: "boolean" };
@@ -4357,22 +3891,59 @@ function inferSchemaFromJson(json) {
4357
3891
  return null;
4358
3892
  }
4359
3893
  }
3894
+ async function resolveSnapshotSpec(relPath) {
3895
+ const dir = getWorkspaceDir();
3896
+ if (!dir) throw new Error("No workspace open — cannot resolve snapshot.");
3897
+ const snap = await snapshots.loadSnapshot(dir, relPath);
3898
+ const tmp = path.join(os.tmpdir(), `api-spector-${crypto.randomUUID()}.${snap.format === "yaml" ? "yaml" : "json"}`);
3899
+ await promises.writeFile(tmp, snap.spec, "utf8");
3900
+ return { specPath: tmp };
3901
+ }
4360
3902
  function registerContractHandlers(ipc) {
4361
3903
  ipc.handle("contract:run", async (_e, payload) => {
4362
- const { mode, requests, envVars, collectionVars = {}, specUrl, specPath, requestBaseUrl } = payload;
3904
+ const { mode, requests, envVars, collectionVars = {}, requestBaseUrl } = payload;
3905
+ let { specUrl, specPath } = payload;
3906
+ if (payload.specSnapshotRelPath) {
3907
+ const resolved = await resolveSnapshotSpec(payload.specSnapshotRelPath);
3908
+ specPath = resolved.specPath;
3909
+ specUrl = void 0;
3910
+ }
4363
3911
  switch (mode) {
4364
3912
  case "consumer":
4365
- return runConsumerContracts(requests, envVars, collectionVars);
3913
+ return snapshots.runConsumerContracts(requests, envVars, collectionVars);
4366
3914
  case "provider":
4367
- return runProviderVerification(requests, envVars, specUrl, specPath, requestBaseUrl);
3915
+ return snapshots.runProviderVerification(requests, envVars, specUrl, specPath, requestBaseUrl);
4368
3916
  case "bidirectional":
4369
- return runBidirectional(requests, envVars, collectionVars, specUrl, specPath, requestBaseUrl);
3917
+ return snapshots.runBidirectional(requests, envVars, collectionVars, specUrl, specPath, requestBaseUrl);
4370
3918
  }
4371
3919
  });
4372
3920
  ipc.handle("contract:inferSchema", (_e, jsonBody) => {
4373
3921
  const schema = inferSchemaFromJson(jsonBody);
4374
3922
  return schema ? JSON.stringify(schema, null, 2) : null;
4375
3923
  });
3924
+ ipc.handle("contract:captureSnapshot", async (_e, opts) => {
3925
+ const dir = getWorkspaceDir();
3926
+ if (!dir) throw new Error("No workspace open — cannot capture snapshot.");
3927
+ const snapshot = await snapshots.captureSnapshot(dir, opts);
3928
+ const relPath = snapshots.relPathOf(snapshot);
3929
+ if (!relPath) throw new Error("Snapshot created but relPath was not attached.");
3930
+ return { relPath, snapshot };
3931
+ });
3932
+ ipc.handle("contract:listSnapshots", async (_e, registered = []) => {
3933
+ const dir = getWorkspaceDir();
3934
+ if (!dir) return [];
3935
+ return snapshots.listSnapshots(dir, registered);
3936
+ });
3937
+ ipc.handle("contract:loadSnapshot", async (_e, relPath) => {
3938
+ const dir = getWorkspaceDir();
3939
+ if (!dir) throw new Error("No workspace open.");
3940
+ return snapshots.loadSnapshot(dir, relPath);
3941
+ });
3942
+ ipc.handle("contract:deleteSnapshot", async (_e, relPath) => {
3943
+ const dir = getWorkspaceDir();
3944
+ if (!dir) return;
3945
+ await snapshots.deleteSnapshot(dir, relPath);
3946
+ });
4376
3947
  }
4377
3948
  function git() {
4378
3949
  const dir = getWorkspaceDir();
@@ -4608,10 +4179,10 @@ function createWindow() {
4608
4179
  }
4609
4180
  electron.app.whenReady().then(async () => {
4610
4181
  if (process.platform !== "darwin") electron.Menu.setApplicationMenu(null);
4611
- await requestCollection.initSecretStore(electron.app.getPath("userData"));
4182
+ await authBuilder.initSecretStore(electron.app.getPath("userData"));
4612
4183
  registerFileHandlers(electron.ipcMain);
4613
4184
  requestCollection.registerRequestHandler(electron.ipcMain);
4614
- requestCollection.registerSecretHandlers(electron.ipcMain);
4185
+ authBuilder.registerSecretHandlers(electron.ipcMain);
4615
4186
  registerImportHandlers(electron.ipcMain);
4616
4187
  registerGenerateHandlers(electron.ipcMain);
4617
4188
  registerRunnerHandler(electron.ipcMain);