@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
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const promises = require("fs/promises");
|
|
3
|
+
const undici = require("undici");
|
|
4
|
+
const jsYaml = require("js-yaml");
|
|
5
|
+
const Ajv = require("ajv");
|
|
6
|
+
const authBuilder = require("./auth-builder-B7-LgcGr.js");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const crypto = require("crypto");
|
|
9
|
+
const ajv$1 = new Ajv({ allErrors: true, strict: false });
|
|
10
|
+
function validateConsumerResponse(contract, actualStatus, actualHeaders, bodyText) {
|
|
11
|
+
const violations = [];
|
|
12
|
+
if (contract.statusCode !== void 0 && actualStatus !== contract.statusCode) {
|
|
13
|
+
violations.push({
|
|
14
|
+
type: "status_mismatch",
|
|
15
|
+
message: `Expected status ${contract.statusCode}, got ${actualStatus}`,
|
|
16
|
+
expected: String(contract.statusCode),
|
|
17
|
+
actual: String(actualStatus)
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
for (const expected of contract.headers ?? []) {
|
|
21
|
+
if (!expected.required) continue;
|
|
22
|
+
const actual = actualHeaders[expected.key.toLowerCase()];
|
|
23
|
+
if (actual === void 0) {
|
|
24
|
+
violations.push({
|
|
25
|
+
type: "missing_header",
|
|
26
|
+
message: `Required header "${expected.key}" is absent`,
|
|
27
|
+
expected: expected.value || "(any)",
|
|
28
|
+
actual: "(absent)"
|
|
29
|
+
});
|
|
30
|
+
} else if (expected.value && actual.split(";")[0].trim().toLowerCase() !== expected.value.split(";")[0].trim().toLowerCase()) {
|
|
31
|
+
violations.push({
|
|
32
|
+
type: "missing_header",
|
|
33
|
+
message: `Header "${expected.key}" has unexpected value`,
|
|
34
|
+
expected: expected.value,
|
|
35
|
+
actual
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (contract.bodySchema?.trim()) {
|
|
40
|
+
let schema;
|
|
41
|
+
try {
|
|
42
|
+
schema = JSON.parse(contract.bodySchema);
|
|
43
|
+
} catch {
|
|
44
|
+
violations.push({
|
|
45
|
+
type: "schema_violation",
|
|
46
|
+
message: "Contract bodySchema is not valid JSON"
|
|
47
|
+
});
|
|
48
|
+
return violations;
|
|
49
|
+
}
|
|
50
|
+
let data;
|
|
51
|
+
try {
|
|
52
|
+
data = JSON.parse(bodyText);
|
|
53
|
+
} catch {
|
|
54
|
+
violations.push({
|
|
55
|
+
type: "schema_violation",
|
|
56
|
+
message: "Response body is not valid JSON — cannot validate against schema"
|
|
57
|
+
});
|
|
58
|
+
return violations;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const validate = ajv$1.compile(schema);
|
|
62
|
+
if (!validate(data)) {
|
|
63
|
+
for (const err of validate.errors ?? []) {
|
|
64
|
+
violations.push({
|
|
65
|
+
type: "schema_violation",
|
|
66
|
+
message: err.message ?? "Schema violation",
|
|
67
|
+
path: err.instancePath || "/"
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
violations.push({
|
|
73
|
+
type: "schema_violation",
|
|
74
|
+
message: `Schema compile error: ${e instanceof Error ? e.message : String(e)}`
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return violations;
|
|
79
|
+
}
|
|
80
|
+
async function executeContract(req, vars) {
|
|
81
|
+
const url = authBuilder.buildUrl(req.url, req.params, vars);
|
|
82
|
+
const start = Date.now();
|
|
83
|
+
try {
|
|
84
|
+
const headers = new undici.Headers();
|
|
85
|
+
for (const h of req.headers) {
|
|
86
|
+
if (h.enabled && h.key) headers.set(authBuilder.interpolate(h.key, vars), authBuilder.interpolate(h.value, vars));
|
|
87
|
+
}
|
|
88
|
+
const authHeaders = await authBuilder.buildAuthHeaders(req.auth, vars);
|
|
89
|
+
for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
|
|
90
|
+
let body;
|
|
91
|
+
if (req.body.mode === "json" && req.body.json) {
|
|
92
|
+
body = authBuilder.interpolate(req.body.json, vars);
|
|
93
|
+
if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
|
|
94
|
+
} else if (req.body.mode === "raw" && req.body.raw) {
|
|
95
|
+
body = authBuilder.interpolate(req.body.raw, vars);
|
|
96
|
+
}
|
|
97
|
+
const resp = await undici.fetch(url, {
|
|
98
|
+
method: req.method,
|
|
99
|
+
headers,
|
|
100
|
+
body: !["GET", "HEAD"].includes(req.method) ? body : void 0
|
|
101
|
+
});
|
|
102
|
+
const bodyText = await resp.text();
|
|
103
|
+
const rawHeaders = {};
|
|
104
|
+
resp.headers.forEach((v, k) => {
|
|
105
|
+
rawHeaders[k] = v;
|
|
106
|
+
});
|
|
107
|
+
const violations = validateConsumerResponse(req.contract, resp.status, rawHeaders, bodyText);
|
|
108
|
+
return {
|
|
109
|
+
requestId: req.id,
|
|
110
|
+
requestName: req.name,
|
|
111
|
+
method: req.method,
|
|
112
|
+
url,
|
|
113
|
+
passed: violations.length === 0,
|
|
114
|
+
violations,
|
|
115
|
+
durationMs: Date.now() - start,
|
|
116
|
+
actualStatus: resp.status
|
|
117
|
+
};
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return {
|
|
120
|
+
requestId: req.id,
|
|
121
|
+
requestName: req.name,
|
|
122
|
+
method: req.method,
|
|
123
|
+
url,
|
|
124
|
+
passed: false,
|
|
125
|
+
violations: [{
|
|
126
|
+
type: "status_mismatch",
|
|
127
|
+
message: `Request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
128
|
+
}],
|
|
129
|
+
durationMs: Date.now() - start
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function runConsumerContracts(requests, envVars, collectionVars = {}) {
|
|
134
|
+
const vars = { ...envVars, ...collectionVars };
|
|
135
|
+
const contractRequests = requests.filter(
|
|
136
|
+
(r) => !r.disabled && r.contract && (r.contract.statusCode !== void 0 || r.contract.bodySchema || r.contract.headers?.length)
|
|
137
|
+
);
|
|
138
|
+
const start = Date.now();
|
|
139
|
+
const results = await Promise.all(contractRequests.map((r) => executeContract(r, vars)));
|
|
140
|
+
const passed = results.filter((r) => r.passed).length;
|
|
141
|
+
return {
|
|
142
|
+
mode: "consumer",
|
|
143
|
+
total: results.length,
|
|
144
|
+
passed,
|
|
145
|
+
failed: results.length - passed,
|
|
146
|
+
results,
|
|
147
|
+
durationMs: Date.now() - start
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
151
|
+
async function loadSpec(specUrl, specPath) {
|
|
152
|
+
if (specUrl) {
|
|
153
|
+
const resp = await undici.fetch(specUrl);
|
|
154
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} loading spec from ${specUrl}`);
|
|
155
|
+
const text = await resp.text();
|
|
156
|
+
const ct = resp.headers.get("content-type") ?? "";
|
|
157
|
+
return ct.includes("yaml") || specUrl.endsWith(".yaml") || specUrl.endsWith(".yml") ? jsYaml.load(text) : JSON.parse(text);
|
|
158
|
+
}
|
|
159
|
+
if (specPath) {
|
|
160
|
+
const raw = await promises.readFile(specPath, "utf8");
|
|
161
|
+
return specPath.endsWith(".yaml") || specPath.endsWith(".yml") ? jsYaml.load(raw) : JSON.parse(raw);
|
|
162
|
+
}
|
|
163
|
+
throw new Error("Either specUrl or specPath must be provided");
|
|
164
|
+
}
|
|
165
|
+
function resolveRef(spec, ref) {
|
|
166
|
+
const parts = ref.replace(/^#\//, "").split("/");
|
|
167
|
+
return parts.reduce((obj, key) => obj?.[key], spec);
|
|
168
|
+
}
|
|
169
|
+
function resolveSchema(spec, obj, seen = /* @__PURE__ */ new Set()) {
|
|
170
|
+
if (!obj || typeof obj !== "object") return obj;
|
|
171
|
+
if (seen.has(obj)) return {};
|
|
172
|
+
if (Array.isArray(obj)) {
|
|
173
|
+
seen.add(obj);
|
|
174
|
+
return obj.map((i) => resolveSchema(spec, i, seen));
|
|
175
|
+
}
|
|
176
|
+
const o = obj;
|
|
177
|
+
if ("$ref" in o) {
|
|
178
|
+
const target = resolveRef(spec, o["$ref"]);
|
|
179
|
+
return resolveSchema(spec, target, seen);
|
|
180
|
+
}
|
|
181
|
+
seen.add(obj);
|
|
182
|
+
return Object.fromEntries(Object.entries(o).map(([k, v]) => [k, resolveSchema(spec, v, seen)]));
|
|
183
|
+
}
|
|
184
|
+
function getServerBases(spec) {
|
|
185
|
+
const servers = spec["servers"] ?? [];
|
|
186
|
+
if (!servers.length) return [""];
|
|
187
|
+
return servers.map((s) => {
|
|
188
|
+
const raw = s.url ?? "";
|
|
189
|
+
try {
|
|
190
|
+
return new URL(raw).pathname.replace(/\/$/, "");
|
|
191
|
+
} catch {
|
|
192
|
+
return raw.replace(/\/$/, "");
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function urlPathname(raw, baseUrl) {
|
|
197
|
+
try {
|
|
198
|
+
const pathname = new URL(raw).pathname;
|
|
199
|
+
if (baseUrl) {
|
|
200
|
+
try {
|
|
201
|
+
const basePath = new URL(baseUrl).pathname.replace(/\/$/, "");
|
|
202
|
+
if (basePath && pathname.startsWith(basePath)) return pathname.slice(basePath.length) || "/";
|
|
203
|
+
} catch {
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return pathname;
|
|
207
|
+
} catch {
|
|
208
|
+
return raw.split("?")[0];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function pathTemplateToRegex(base, template) {
|
|
212
|
+
const combined = (base + template).replace(/\/+/g, "/");
|
|
213
|
+
const pattern = combined.replace(/\{[^}]+\}/g, "[^/]+");
|
|
214
|
+
return new RegExp("^" + pattern + "/?$");
|
|
215
|
+
}
|
|
216
|
+
function findOperation(spec, method, reqUrl, requestBaseUrl) {
|
|
217
|
+
const bases = getServerBases(spec);
|
|
218
|
+
const pathname = urlPathname(reqUrl, requestBaseUrl);
|
|
219
|
+
const paths = spec["paths"] ?? {};
|
|
220
|
+
for (const [template, pathItem] of Object.entries(paths)) {
|
|
221
|
+
const resolved = resolveSchema(spec, pathItem);
|
|
222
|
+
for (const base of bases) {
|
|
223
|
+
if (pathTemplateToRegex(base, template).test(pathname)) {
|
|
224
|
+
const op = resolved[method.toLowerCase()];
|
|
225
|
+
return op ? { pathTemplate: template, operation: op } : null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
function validateRequestAgainstSpec(spec, req, envVars, requestBaseUrl) {
|
|
232
|
+
const violations = [];
|
|
233
|
+
const vars = envVars;
|
|
234
|
+
const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
|
235
|
+
const match = findOperation(spec, req.method, url, requestBaseUrl);
|
|
236
|
+
if (!match) {
|
|
237
|
+
violations.push({
|
|
238
|
+
type: "unknown_path",
|
|
239
|
+
message: `No operation found in spec for ${req.method} ${url}`
|
|
240
|
+
});
|
|
241
|
+
return violations;
|
|
242
|
+
}
|
|
243
|
+
const { operation } = match;
|
|
244
|
+
if (req.body.mode === "json" && req.body.json?.trim()) {
|
|
245
|
+
const requestBody = resolveSchema(spec, operation["requestBody"]);
|
|
246
|
+
const content = requestBody?.["content"] ?? {};
|
|
247
|
+
const jsonContent = content["application/json"];
|
|
248
|
+
if (jsonContent?.["schema"]) {
|
|
249
|
+
try {
|
|
250
|
+
const data = JSON.parse(authBuilder.interpolate(req.body.json, vars));
|
|
251
|
+
const schema = resolveSchema(spec, jsonContent["schema"]);
|
|
252
|
+
const validate = ajv.compile(schema);
|
|
253
|
+
if (!validate(data)) {
|
|
254
|
+
for (const err of validate.errors ?? []) {
|
|
255
|
+
violations.push({
|
|
256
|
+
type: "request_body_invalid",
|
|
257
|
+
message: err.message ?? "Request body schema violation",
|
|
258
|
+
path: err.instancePath || "/"
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch (e) {
|
|
263
|
+
violations.push({
|
|
264
|
+
type: "request_body_invalid",
|
|
265
|
+
message: `Could not validate request body: ${e instanceof Error ? e.message : String(e)}`
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const parameters = resolveSchema(spec, operation["parameters"] ?? []);
|
|
271
|
+
for (const param of parameters) {
|
|
272
|
+
const p = param;
|
|
273
|
+
if (p["in"] === "query" && p["required"] === true) {
|
|
274
|
+
const name = p["name"];
|
|
275
|
+
if (!req.params.some((kv) => kv.enabled && kv.key === name)) {
|
|
276
|
+
violations.push({
|
|
277
|
+
type: "request_body_invalid",
|
|
278
|
+
message: `Required query parameter "${name}" is missing`,
|
|
279
|
+
path: `query.${name}`
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return violations;
|
|
285
|
+
}
|
|
286
|
+
async function runProviderVerification(requests, envVars, specUrl, specPath, requestBaseUrl) {
|
|
287
|
+
const spec = await loadSpec(specUrl, specPath);
|
|
288
|
+
const start = Date.now();
|
|
289
|
+
const activeRequests = requests.filter((r) => !r.disabled);
|
|
290
|
+
const results = activeRequests.map((req) => {
|
|
291
|
+
const violations = validateRequestAgainstSpec(spec, req, envVars, requestBaseUrl);
|
|
292
|
+
const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => envVars[k] ?? `{{${k}}}`);
|
|
293
|
+
return {
|
|
294
|
+
requestId: req.id,
|
|
295
|
+
requestName: req.name,
|
|
296
|
+
method: req.method,
|
|
297
|
+
url,
|
|
298
|
+
passed: violations.length === 0,
|
|
299
|
+
violations
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
const passed = results.filter((r) => r.passed).length;
|
|
303
|
+
return {
|
|
304
|
+
mode: "provider",
|
|
305
|
+
total: results.length,
|
|
306
|
+
passed,
|
|
307
|
+
failed: results.length - passed,
|
|
308
|
+
results,
|
|
309
|
+
durationMs: Date.now() - start
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function checkSchemaCompatibility(consumerSchema, providerSchema, path2 = "") {
|
|
313
|
+
const violations = [];
|
|
314
|
+
if (!consumerSchema || !providerSchema) return violations;
|
|
315
|
+
const cType = consumerSchema["type"];
|
|
316
|
+
const pType = providerSchema["type"];
|
|
317
|
+
if (cType && pType && cType !== pType) {
|
|
318
|
+
const ok = cType === "integer" && pType === "number" || cType === "number" && pType === "integer";
|
|
319
|
+
if (!ok) {
|
|
320
|
+
violations.push({
|
|
321
|
+
type: "schema_incompatible",
|
|
322
|
+
message: `Type mismatch${path2 ? ` at "${path2}"` : ""}: consumer expects "${cType}", provider offers "${pType}"`,
|
|
323
|
+
path: path2 || "/",
|
|
324
|
+
expected: cType,
|
|
325
|
+
actual: pType
|
|
326
|
+
});
|
|
327
|
+
return violations;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (cType === "array" || Array.isArray(consumerSchema["items"])) {
|
|
331
|
+
const cItems = consumerSchema["items"];
|
|
332
|
+
const pItems = providerSchema["items"];
|
|
333
|
+
if (cItems && pItems) {
|
|
334
|
+
violations.push(...checkSchemaCompatibility(cItems, pItems, path2 ? `${path2}[]` : "[]"));
|
|
335
|
+
}
|
|
336
|
+
return violations;
|
|
337
|
+
}
|
|
338
|
+
if (cType === "object" || consumerSchema["properties"]) {
|
|
339
|
+
const cProps = consumerSchema["properties"] ?? {};
|
|
340
|
+
const pProps = providerSchema["properties"] ?? {};
|
|
341
|
+
const cRequired = consumerSchema["required"] ?? [];
|
|
342
|
+
for (const field of cRequired) {
|
|
343
|
+
const fieldPath = path2 ? `${path2}.${field}` : field;
|
|
344
|
+
if (!(field in pProps)) {
|
|
345
|
+
violations.push({
|
|
346
|
+
type: "schema_incompatible",
|
|
347
|
+
message: `Consumer requires field "${fieldPath}" which is not defined in provider schema`,
|
|
348
|
+
path: fieldPath,
|
|
349
|
+
expected: "(defined)",
|
|
350
|
+
actual: "(absent)"
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const [field, cPropSchema] of Object.entries(cProps)) {
|
|
355
|
+
if (field in pProps) {
|
|
356
|
+
const fieldPath = path2 ? `${path2}.${field}` : field;
|
|
357
|
+
violations.push(...checkSchemaCompatibility(
|
|
358
|
+
cPropSchema,
|
|
359
|
+
pProps[field],
|
|
360
|
+
fieldPath
|
|
361
|
+
));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return violations;
|
|
366
|
+
}
|
|
367
|
+
function getProviderResponseSchema(spec, req, envVars, statusCode, requestBaseUrl) {
|
|
368
|
+
const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => envVars[k] ?? `{{${k}}}`);
|
|
369
|
+
const match = findOperation(spec, req.method, url, requestBaseUrl);
|
|
370
|
+
if (!match) return null;
|
|
371
|
+
const responses = match.operation["responses"] ?? {};
|
|
372
|
+
const candidates = [String(statusCode), `${String(statusCode)[0]}xx`, "2XX", "2xx", "default"];
|
|
373
|
+
for (const candidate of candidates) {
|
|
374
|
+
const resp = responses[candidate];
|
|
375
|
+
if (resp) {
|
|
376
|
+
const resolved = resolveSchema(spec, resp);
|
|
377
|
+
const content = resolved["content"] ?? {};
|
|
378
|
+
const json = content["application/json"];
|
|
379
|
+
if (json?.["schema"]) {
|
|
380
|
+
return resolveSchema(spec, json["schema"]);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
async function executeRequest(req, vars) {
|
|
387
|
+
const url = authBuilder.buildUrl(req.url, req.params, vars);
|
|
388
|
+
const start = Date.now();
|
|
389
|
+
try {
|
|
390
|
+
const headers = new undici.Headers();
|
|
391
|
+
for (const h of req.headers) {
|
|
392
|
+
if (h.enabled && h.key) headers.set(authBuilder.interpolate(h.key, vars), authBuilder.interpolate(h.value, vars));
|
|
393
|
+
}
|
|
394
|
+
const authHeaders = await authBuilder.buildAuthHeaders(req.auth, vars);
|
|
395
|
+
for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
|
|
396
|
+
let body;
|
|
397
|
+
if (req.body.mode === "json" && req.body.json) {
|
|
398
|
+
body = authBuilder.interpolate(req.body.json, vars);
|
|
399
|
+
if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
|
|
400
|
+
}
|
|
401
|
+
const resp = await undici.fetch(url, {
|
|
402
|
+
method: req.method,
|
|
403
|
+
headers,
|
|
404
|
+
body: !["GET", "HEAD"].includes(req.method) ? body : void 0
|
|
405
|
+
});
|
|
406
|
+
const bodyText = await resp.text();
|
|
407
|
+
const rawHdrs = {};
|
|
408
|
+
resp.headers.forEach((v, k) => {
|
|
409
|
+
rawHdrs[k] = v;
|
|
410
|
+
});
|
|
411
|
+
return { status: resp.status, headers: rawHdrs, body: bodyText, durationMs: Date.now() - start };
|
|
412
|
+
} catch (err) {
|
|
413
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function runBidirectional(requests, envVars, collectionVars = {}, specUrl, specPath, requestBaseUrl) {
|
|
417
|
+
const spec = await loadSpec(specUrl, specPath);
|
|
418
|
+
const vars = { ...envVars, ...collectionVars };
|
|
419
|
+
const start = Date.now();
|
|
420
|
+
const contractRequests = requests.filter(
|
|
421
|
+
(r) => !r.disabled && r.contract && (r.contract.statusCode !== void 0 || r.contract.bodySchema || r.contract.headers?.length)
|
|
422
|
+
);
|
|
423
|
+
const results = await Promise.all(contractRequests.map(async (req) => {
|
|
424
|
+
const url = authBuilder.buildUrl(req.url, req.params, vars);
|
|
425
|
+
const violations = [];
|
|
426
|
+
const expectedStatus = req.contract.statusCode ?? 200;
|
|
427
|
+
const consumerSchema = req.contract.bodySchema ? (() => {
|
|
428
|
+
try {
|
|
429
|
+
return JSON.parse(req.contract.bodySchema);
|
|
430
|
+
} catch {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
})() : null;
|
|
434
|
+
if (consumerSchema) {
|
|
435
|
+
const providerSchema = getProviderResponseSchema(spec, req, vars, expectedStatus, requestBaseUrl);
|
|
436
|
+
if (!providerSchema) {
|
|
437
|
+
violations.push({
|
|
438
|
+
type: "schema_incompatible",
|
|
439
|
+
message: `No response schema found in spec for ${req.method} ${url} → ${expectedStatus}`
|
|
440
|
+
});
|
|
441
|
+
} else {
|
|
442
|
+
violations.push(...checkSchemaCompatibility(consumerSchema, providerSchema));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const result = await executeRequest(req, vars);
|
|
446
|
+
if (result instanceof Error) {
|
|
447
|
+
violations.push({ type: "status_mismatch", message: `Request failed: ${result.message}` });
|
|
448
|
+
return { requestId: req.id, requestName: req.name, method: req.method, url, passed: false, violations };
|
|
449
|
+
}
|
|
450
|
+
const liveViolations = validateConsumerResponse(
|
|
451
|
+
req.contract,
|
|
452
|
+
result.status,
|
|
453
|
+
result.headers,
|
|
454
|
+
result.body
|
|
455
|
+
);
|
|
456
|
+
violations.push(...liveViolations);
|
|
457
|
+
return {
|
|
458
|
+
requestId: req.id,
|
|
459
|
+
requestName: req.name,
|
|
460
|
+
method: req.method,
|
|
461
|
+
url,
|
|
462
|
+
passed: violations.length === 0,
|
|
463
|
+
violations,
|
|
464
|
+
durationMs: result.durationMs,
|
|
465
|
+
actualStatus: result.status
|
|
466
|
+
};
|
|
467
|
+
}));
|
|
468
|
+
const passed = results.filter((r) => r.passed).length;
|
|
469
|
+
return {
|
|
470
|
+
mode: "bidirectional",
|
|
471
|
+
total: results.length,
|
|
472
|
+
passed,
|
|
473
|
+
failed: results.length - passed,
|
|
474
|
+
results,
|
|
475
|
+
durationMs: Date.now() - start
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const SNAPSHOT_DIR = "contracts";
|
|
479
|
+
function safeName(raw) {
|
|
480
|
+
return raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "spec";
|
|
481
|
+
}
|
|
482
|
+
function sha256(text) {
|
|
483
|
+
return crypto.createHash("sha256").update(text).digest("hex");
|
|
484
|
+
}
|
|
485
|
+
function detectFormat(source, contentType) {
|
|
486
|
+
const lc = source.toLowerCase();
|
|
487
|
+
if (lc.endsWith(".yaml") || lc.endsWith(".yml")) return "yaml";
|
|
488
|
+
if (lc.endsWith(".json")) return "json";
|
|
489
|
+
if (contentType.includes("yaml")) return "yaml";
|
|
490
|
+
return "json";
|
|
491
|
+
}
|
|
492
|
+
function tryExtractSpecVersion(specText, format) {
|
|
493
|
+
try {
|
|
494
|
+
const parsed = format === "yaml" ? jsYaml.load(specText) : JSON.parse(specText);
|
|
495
|
+
const info = parsed?.info;
|
|
496
|
+
if (info && typeof info.version === "string") return info.version;
|
|
497
|
+
} catch {
|
|
498
|
+
}
|
|
499
|
+
return void 0;
|
|
500
|
+
}
|
|
501
|
+
async function captureSnapshot(workspaceDir, opts) {
|
|
502
|
+
const { specUrl, specPath } = opts;
|
|
503
|
+
if (!specUrl && !specPath) throw new Error("captureSnapshot: specUrl or specPath is required");
|
|
504
|
+
let specText;
|
|
505
|
+
let format;
|
|
506
|
+
let source;
|
|
507
|
+
if (specUrl) {
|
|
508
|
+
const resp = await undici.fetch(specUrl);
|
|
509
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} loading spec from ${specUrl}`);
|
|
510
|
+
specText = await resp.text();
|
|
511
|
+
format = detectFormat(specUrl, resp.headers.get("content-type") ?? "");
|
|
512
|
+
source = specUrl;
|
|
513
|
+
} else {
|
|
514
|
+
specText = await promises.readFile(specPath, "utf8");
|
|
515
|
+
format = detectFormat(specPath, "");
|
|
516
|
+
source = specPath;
|
|
517
|
+
}
|
|
518
|
+
const id = crypto.randomUUID();
|
|
519
|
+
const specVersion = tryExtractSpecVersion(specText, format);
|
|
520
|
+
let name = opts.name?.trim();
|
|
521
|
+
if (!name) {
|
|
522
|
+
try {
|
|
523
|
+
name = new URL(source).hostname;
|
|
524
|
+
} catch {
|
|
525
|
+
name = source.split(/[\\/]/).pop() ?? "spec";
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (specVersion && !name.includes(specVersion)) name = `${name} ${specVersion}`;
|
|
529
|
+
const snapshot = {
|
|
530
|
+
version: "1.0",
|
|
531
|
+
id,
|
|
532
|
+
name,
|
|
533
|
+
source,
|
|
534
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
535
|
+
format,
|
|
536
|
+
specVersion,
|
|
537
|
+
spec: specText,
|
|
538
|
+
sha256: sha256(specText)
|
|
539
|
+
};
|
|
540
|
+
const relPath = path.join(SNAPSHOT_DIR, `${safeName(name)}-${id.slice(0, 8)}.contract.json`);
|
|
541
|
+
const absPath = path.resolve(workspaceDir, relPath);
|
|
542
|
+
await promises.mkdir(path.join(workspaceDir, SNAPSHOT_DIR), { recursive: true });
|
|
543
|
+
await promises.writeFile(absPath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
544
|
+
Object.defineProperty(snapshot, "__relPath", { value: relPath, enumerable: false });
|
|
545
|
+
return snapshot;
|
|
546
|
+
}
|
|
547
|
+
function relPathOf(snapshot) {
|
|
548
|
+
const hidden = snapshot.__relPath;
|
|
549
|
+
return hidden;
|
|
550
|
+
}
|
|
551
|
+
async function loadSnapshot(workspaceDir, relPath) {
|
|
552
|
+
const raw = await promises.readFile(path.resolve(workspaceDir, relPath), "utf8");
|
|
553
|
+
return JSON.parse(raw);
|
|
554
|
+
}
|
|
555
|
+
async function listSnapshots(workspaceDir, registered = []) {
|
|
556
|
+
const seen = new Set(registered);
|
|
557
|
+
try {
|
|
558
|
+
const entries = await promises.readdir(path.join(workspaceDir, SNAPSHOT_DIR));
|
|
559
|
+
for (const f of entries) {
|
|
560
|
+
if (f.endsWith(".contract.json")) seen.add(path.join(SNAPSHOT_DIR, f));
|
|
561
|
+
}
|
|
562
|
+
} catch {
|
|
563
|
+
}
|
|
564
|
+
const out = [];
|
|
565
|
+
for (const relPath of seen) {
|
|
566
|
+
try {
|
|
567
|
+
const snapshot = await loadSnapshot(workspaceDir, relPath);
|
|
568
|
+
out.push({ relPath, snapshot });
|
|
569
|
+
} catch {
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
out.sort((a, b) => b.snapshot.capturedAt.localeCompare(a.snapshot.capturedAt));
|
|
573
|
+
return out;
|
|
574
|
+
}
|
|
575
|
+
async function deleteSnapshot(workspaceDir, relPath) {
|
|
576
|
+
try {
|
|
577
|
+
await promises.unlink(path.resolve(workspaceDir, relPath));
|
|
578
|
+
} catch {
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
exports.captureSnapshot = captureSnapshot;
|
|
582
|
+
exports.deleteSnapshot = deleteSnapshot;
|
|
583
|
+
exports.listSnapshots = listSnapshots;
|
|
584
|
+
exports.loadSnapshot = loadSnapshot;
|
|
585
|
+
exports.relPathOf = relPathOf;
|
|
586
|
+
exports.runBidirectional = runBidirectional;
|
|
587
|
+
exports.runConsumerContracts = runConsumerContracts;
|
|
588
|
+
exports.runProviderVerification = runProviderVerification;
|