@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/bin/cli.js +11 -3
- package/out/main/chunks/auth-builder-B7-LgcGr.js +373 -0
- package/out/main/chunks/{request-collection-Dx0ZqB54.js → request-collection-DMVlm0PA.js} +23 -349
- package/out/main/chunks/snapshots-C7YbGHM7.js +588 -0
- package/out/main/contract.js +210 -0
- package/out/main/index.js +78 -507
- package/out/main/runner.js +17 -16
- package/out/preload/index.js +4 -0
- package/out/renderer/assets/{index-DVaubmCJ.css → index-CxdQOFBM.css} +1 -1
- package/out/renderer/assets/{index-C_vjoxxA.js → index-DdHHEKaz.js} +997 -843
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3090
|
-
let vars =
|
|
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(
|
|
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 =
|
|
3112
|
+
vars = authBuilder.mergeVars(updatedEnvVars, updatedCollectionVars, updatedGlobals, localVars, dynamicVars);
|
|
3110
3113
|
}
|
|
3111
|
-
const resolvedUrl =
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
3396
|
-
const tokenUrl =
|
|
3397
|
-
const clientId =
|
|
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
|
|
3403
|
+
clientSecret = await authBuilder.getSecret(auth.oauth2ClientSecretRef) ?? "";
|
|
3401
3404
|
}
|
|
3402
|
-
clientSecret =
|
|
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 =
|
|
3478
|
-
const clientId =
|
|
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
|
|
3484
|
+
clientSecret = await authBuilder.getSecret(auth.oauth2ClientSecretRef) ?? "";
|
|
3482
3485
|
}
|
|
3483
|
-
clientSecret =
|
|
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 = {},
|
|
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
|
|
4182
|
+
await authBuilder.initSecretStore(electron.app.getPath("userData"));
|
|
4612
4183
|
registerFileHandlers(electron.ipcMain);
|
|
4613
4184
|
requestCollection.registerRequestHandler(electron.ipcMain);
|
|
4614
|
-
|
|
4185
|
+
authBuilder.registerSecretHandlers(electron.ipcMain);
|
|
4615
4186
|
registerImportHandlers(electron.ipcMain);
|
|
4616
4187
|
registerGenerateHandlers(electron.ipcMain);
|
|
4617
4188
|
registerRunnerHandler(electron.ipcMain);
|