@testsmith/api-spector 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +11 -7
- package/out/main/agents.js +1 -1
- package/out/main/chunks/environments-iM3SUM-4.js +28 -0
- package/out/main/chunks/{handle-C0IQL-Vl.js → handle-BtRMtQJg.js} +3 -1
- package/out/main/chunks/{import-C5Ngq8hk.js → import-CZxL6J96.js} +4 -4
- package/out/main/chunks/{recorder-DFxJgn9c.js → recorder-0Ij921El.js} +1 -1
- package/out/main/chunks/request-collection-CBuPjBwa.js +118 -0
- package/out/main/chunks/{request-collection-8TOVNXE0.js → request-exec-DbNHbA8x.js} +602 -146
- package/out/main/chunks/snapshots-rV5tPwAd.js +1658 -0
- package/out/main/chunks/{soap-handler-B9x_YCtj.js → soap-handler-CqXVreNa.js} +3 -3
- package/out/main/contract.js +508 -91
- package/out/main/index.js +158 -87
- package/out/main/record.js +3 -3
- package/out/main/runner.js +23 -20
- package/out/main/wsdl.js +4 -4
- package/out/preload/index.js +5 -1
- package/out/renderer/assets/index-DWiG0dQo.css +2 -0
- package/out/renderer/assets/{index-BjjOh-E5.js → index-htBErIFG.js} +915 -176
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/readme.md +2 -2
- package/out/main/chunks/auth-builder-CUs9yzOF.js +0 -623
- package/out/main/chunks/snapshots-UFd3XgSS.js +0 -928
- package/out/renderer/assets/index-CjvjKHtF.css +0 -2
|
@@ -1,928 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
const undici = require("undici");
|
|
3
|
-
const authBuilder = require("./auth-builder-CUs9yzOF.js");
|
|
4
|
-
const promises = require("fs/promises");
|
|
5
|
-
const jsYaml = require("js-yaml");
|
|
6
|
-
const Ajv = require("ajv");
|
|
7
|
-
const path = require("path");
|
|
8
|
-
const crypto = require("crypto");
|
|
9
|
-
const MATCH_KEY = "__match";
|
|
10
|
-
function isMatcher(node) {
|
|
11
|
-
return typeof node === "object" && node !== null && !Array.isArray(node) && typeof node[MATCH_KEY] === "string";
|
|
12
|
-
}
|
|
13
|
-
function compileMatcher(m) {
|
|
14
|
-
switch (m[MATCH_KEY]) {
|
|
15
|
-
case "type":
|
|
16
|
-
return compileMatcherExample(m.value, false);
|
|
17
|
-
case "integer":
|
|
18
|
-
return { type: "integer" };
|
|
19
|
-
case "decimal":
|
|
20
|
-
case "number":
|
|
21
|
-
return { type: "number" };
|
|
22
|
-
case "boolean":
|
|
23
|
-
return { type: "boolean" };
|
|
24
|
-
case "string":
|
|
25
|
-
return { type: "string" };
|
|
26
|
-
case "null":
|
|
27
|
-
return { type: "null" };
|
|
28
|
-
case "regex":
|
|
29
|
-
return { type: "string", pattern: m.regex ?? ".*" };
|
|
30
|
-
case "datetime":
|
|
31
|
-
case "timestamp":
|
|
32
|
-
return { type: "string", format: "date-time" };
|
|
33
|
-
case "date":
|
|
34
|
-
return { type: "string", format: "date" };
|
|
35
|
-
case "time":
|
|
36
|
-
return { type: "string", format: "time" };
|
|
37
|
-
case "eachLike": {
|
|
38
|
-
const schema = { type: "array", items: compileMatcherExample(m.value, false) };
|
|
39
|
-
schema["minItems"] = typeof m.min === "number" ? m.min : 1;
|
|
40
|
-
return schema;
|
|
41
|
-
}
|
|
42
|
-
default:
|
|
43
|
-
return {};
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
function compileMatcherExample(node, exact = true) {
|
|
47
|
-
if (isMatcher(node)) return compileMatcher(node);
|
|
48
|
-
if (node === null) return { type: "null" };
|
|
49
|
-
const t = typeof node;
|
|
50
|
-
if (t === "boolean") return exact ? { const: node } : { type: "boolean" };
|
|
51
|
-
if (t === "number") return exact ? { const: node } : Number.isInteger(node) ? { type: "integer" } : { type: "number" };
|
|
52
|
-
if (t === "string") return exact ? { const: node } : { type: "string" };
|
|
53
|
-
if (Array.isArray(node)) {
|
|
54
|
-
if (node.length === 0) return { type: "array" };
|
|
55
|
-
const schema = { type: "array", items: node.map((n) => compileMatcherExample(n, exact)) };
|
|
56
|
-
if (exact) schema["minItems"] = node.length;
|
|
57
|
-
return schema;
|
|
58
|
-
}
|
|
59
|
-
if (t === "object") {
|
|
60
|
-
const obj = node;
|
|
61
|
-
const properties = {};
|
|
62
|
-
const required = [];
|
|
63
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
64
|
-
properties[key] = compileMatcherExample(value, exact);
|
|
65
|
-
required.push(key);
|
|
66
|
-
}
|
|
67
|
-
return { type: "object", properties, required, additionalProperties: true };
|
|
68
|
-
}
|
|
69
|
-
return {};
|
|
70
|
-
}
|
|
71
|
-
const ajv$1 = new Ajv({ allErrors: true, strict: false });
|
|
72
|
-
function hasContract(contract) {
|
|
73
|
-
return !!contract && (contract.statusCode !== void 0 || !!contract.bodySchema || !!contract.bodyMatcher || !!contract.headers?.length);
|
|
74
|
-
}
|
|
75
|
-
function validateConsumerResponse(contract, actualStatus, actualHeaders, bodyText) {
|
|
76
|
-
const violations = [];
|
|
77
|
-
if (contract.statusCode !== void 0 && actualStatus !== contract.statusCode) {
|
|
78
|
-
violations.push({
|
|
79
|
-
type: "status_mismatch",
|
|
80
|
-
message: `Expected status ${contract.statusCode}, got ${actualStatus}`,
|
|
81
|
-
expected: String(contract.statusCode),
|
|
82
|
-
actual: String(actualStatus)
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
for (const expected of contract.headers ?? []) {
|
|
86
|
-
if (!expected.required) continue;
|
|
87
|
-
const actual = actualHeaders[expected.key.toLowerCase()];
|
|
88
|
-
if (actual === void 0) {
|
|
89
|
-
violations.push({
|
|
90
|
-
type: "missing_header",
|
|
91
|
-
message: `Required header "${expected.key}" is absent`,
|
|
92
|
-
expected: expected.value || "(any)",
|
|
93
|
-
actual: "(absent)"
|
|
94
|
-
});
|
|
95
|
-
} else if (expected.value && actual.split(";")[0].trim().toLowerCase() !== expected.value.split(";")[0].trim().toLowerCase()) {
|
|
96
|
-
violations.push({
|
|
97
|
-
type: "missing_header",
|
|
98
|
-
message: `Header "${expected.key}" has unexpected value`,
|
|
99
|
-
expected: expected.value,
|
|
100
|
-
actual
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
if (contract.bodySchema?.trim()) {
|
|
105
|
-
let schema;
|
|
106
|
-
try {
|
|
107
|
-
schema = JSON.parse(contract.bodySchema);
|
|
108
|
-
} catch {
|
|
109
|
-
violations.push({
|
|
110
|
-
type: "schema_violation",
|
|
111
|
-
message: "Contract bodySchema is not valid JSON"
|
|
112
|
-
});
|
|
113
|
-
return violations;
|
|
114
|
-
}
|
|
115
|
-
violations.push(...validateBody(schema, bodyText));
|
|
116
|
-
}
|
|
117
|
-
if (contract.bodyMatcher?.trim()) {
|
|
118
|
-
let example;
|
|
119
|
-
try {
|
|
120
|
-
example = JSON.parse(contract.bodyMatcher);
|
|
121
|
-
} catch {
|
|
122
|
-
violations.push({
|
|
123
|
-
type: "schema_violation",
|
|
124
|
-
message: "Contract bodyMatcher is not valid JSON"
|
|
125
|
-
});
|
|
126
|
-
return violations;
|
|
127
|
-
}
|
|
128
|
-
violations.push(...validateBody(compileMatcherExample(example), bodyText));
|
|
129
|
-
}
|
|
130
|
-
return violations;
|
|
131
|
-
}
|
|
132
|
-
function validateBody(schema, bodyText) {
|
|
133
|
-
const violations = [];
|
|
134
|
-
let data;
|
|
135
|
-
try {
|
|
136
|
-
data = JSON.parse(bodyText);
|
|
137
|
-
} catch {
|
|
138
|
-
violations.push({
|
|
139
|
-
type: "schema_violation",
|
|
140
|
-
message: "Response body is not valid JSON — cannot validate against schema"
|
|
141
|
-
});
|
|
142
|
-
return violations;
|
|
143
|
-
}
|
|
144
|
-
try {
|
|
145
|
-
const validate = ajv$1.compile(schema);
|
|
146
|
-
if (!validate(data)) {
|
|
147
|
-
for (const err of validate.errors ?? []) {
|
|
148
|
-
violations.push({
|
|
149
|
-
type: "schema_violation",
|
|
150
|
-
message: err.message ?? "Schema violation",
|
|
151
|
-
path: err.instancePath || "/"
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
} catch (e) {
|
|
156
|
-
violations.push({
|
|
157
|
-
type: "schema_violation",
|
|
158
|
-
message: `Schema compile error: ${e instanceof Error ? e.message : String(e)}`
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
return violations;
|
|
162
|
-
}
|
|
163
|
-
async function executeContract(req, vars) {
|
|
164
|
-
const url = authBuilder.buildUrl(req.url, req.params, vars);
|
|
165
|
-
const start = Date.now();
|
|
166
|
-
try {
|
|
167
|
-
const headers = new undici.Headers();
|
|
168
|
-
for (const h of req.headers) {
|
|
169
|
-
if (h.enabled && h.key) headers.set(authBuilder.interpolate(h.key, vars), authBuilder.interpolate(h.value, vars));
|
|
170
|
-
}
|
|
171
|
-
const authHeaders = await authBuilder.buildAuthHeaders(req.auth, vars);
|
|
172
|
-
for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
|
|
173
|
-
let body;
|
|
174
|
-
if (req.body.mode === "json" && req.body.json) {
|
|
175
|
-
body = authBuilder.interpolate(req.body.json, vars);
|
|
176
|
-
if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
|
|
177
|
-
} else if (req.body.mode === "raw" && req.body.raw) {
|
|
178
|
-
body = authBuilder.interpolate(req.body.raw, vars);
|
|
179
|
-
}
|
|
180
|
-
const resp = await undici.fetch(url, {
|
|
181
|
-
method: req.method,
|
|
182
|
-
headers,
|
|
183
|
-
body: !["GET", "HEAD"].includes(req.method) ? body : void 0
|
|
184
|
-
});
|
|
185
|
-
const bodyText = await resp.text();
|
|
186
|
-
const rawHeaders = {};
|
|
187
|
-
resp.headers.forEach((v, k) => {
|
|
188
|
-
rawHeaders[k] = v;
|
|
189
|
-
});
|
|
190
|
-
const violations = validateConsumerResponse(req.contract, resp.status, rawHeaders, bodyText);
|
|
191
|
-
return {
|
|
192
|
-
requestId: req.id,
|
|
193
|
-
requestName: req.name,
|
|
194
|
-
method: req.method,
|
|
195
|
-
url,
|
|
196
|
-
passed: violations.length === 0,
|
|
197
|
-
violations,
|
|
198
|
-
durationMs: Date.now() - start,
|
|
199
|
-
actualStatus: resp.status
|
|
200
|
-
};
|
|
201
|
-
} catch (err) {
|
|
202
|
-
return {
|
|
203
|
-
requestId: req.id,
|
|
204
|
-
requestName: req.name,
|
|
205
|
-
method: req.method,
|
|
206
|
-
url,
|
|
207
|
-
passed: false,
|
|
208
|
-
violations: [{
|
|
209
|
-
type: "status_mismatch",
|
|
210
|
-
message: `Request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
211
|
-
}],
|
|
212
|
-
durationMs: Date.now() - start
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
async function runConsumerContracts(requests, envVars, collectionVars = {}) {
|
|
217
|
-
const vars = { ...envVars, ...collectionVars };
|
|
218
|
-
const contractRequests = requests.filter((r) => !r.disabled && hasContract(r.contract));
|
|
219
|
-
const start = Date.now();
|
|
220
|
-
const results = await Promise.all(contractRequests.map((r) => executeContract(r, vars)));
|
|
221
|
-
const passed = results.filter((r) => r.passed).length;
|
|
222
|
-
return {
|
|
223
|
-
mode: "consumer",
|
|
224
|
-
total: results.length,
|
|
225
|
-
passed,
|
|
226
|
-
failed: results.length - passed,
|
|
227
|
-
results,
|
|
228
|
-
durationMs: Date.now() - start
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
232
|
-
async function loadSpec(specUrl, specPath) {
|
|
233
|
-
if (specUrl) {
|
|
234
|
-
const resp = await undici.fetch(specUrl);
|
|
235
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status} loading spec from ${specUrl}`);
|
|
236
|
-
const text = await resp.text();
|
|
237
|
-
const ct = resp.headers.get("content-type") ?? "";
|
|
238
|
-
return ct.includes("yaml") || specUrl.endsWith(".yaml") || specUrl.endsWith(".yml") ? jsYaml.load(text) : JSON.parse(text);
|
|
239
|
-
}
|
|
240
|
-
if (specPath) {
|
|
241
|
-
const raw = await promises.readFile(specPath, "utf8");
|
|
242
|
-
return specPath.endsWith(".yaml") || specPath.endsWith(".yml") ? jsYaml.load(raw) : JSON.parse(raw);
|
|
243
|
-
}
|
|
244
|
-
throw new Error("Either specUrl or specPath must be provided");
|
|
245
|
-
}
|
|
246
|
-
function resolveRef(spec, ref) {
|
|
247
|
-
const parts = ref.replace(/^#\//, "").split("/");
|
|
248
|
-
return parts.reduce((obj, key) => obj?.[key], spec);
|
|
249
|
-
}
|
|
250
|
-
function resolveSchema(spec, obj, seen = /* @__PURE__ */ new Set()) {
|
|
251
|
-
if (!obj || typeof obj !== "object") return obj;
|
|
252
|
-
if (seen.has(obj)) return {};
|
|
253
|
-
if (Array.isArray(obj)) {
|
|
254
|
-
seen.add(obj);
|
|
255
|
-
return obj.map((i) => resolveSchema(spec, i, seen));
|
|
256
|
-
}
|
|
257
|
-
const o = obj;
|
|
258
|
-
if ("$ref" in o) {
|
|
259
|
-
const target = resolveRef(spec, o["$ref"]);
|
|
260
|
-
return resolveSchema(spec, target, seen);
|
|
261
|
-
}
|
|
262
|
-
seen.add(obj);
|
|
263
|
-
return Object.fromEntries(Object.entries(o).map(([k, v]) => [k, resolveSchema(spec, v, seen)]));
|
|
264
|
-
}
|
|
265
|
-
function getServerBases(spec) {
|
|
266
|
-
const servers = spec["servers"] ?? [];
|
|
267
|
-
if (!servers.length) return [""];
|
|
268
|
-
return servers.map((s) => {
|
|
269
|
-
const raw = s.url ?? "";
|
|
270
|
-
try {
|
|
271
|
-
return new URL(raw).pathname.replace(/\/$/, "");
|
|
272
|
-
} catch {
|
|
273
|
-
return raw.replace(/\/$/, "");
|
|
274
|
-
}
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
function urlPathname(raw, baseUrl) {
|
|
278
|
-
try {
|
|
279
|
-
const pathname = new URL(raw).pathname;
|
|
280
|
-
if (baseUrl) {
|
|
281
|
-
try {
|
|
282
|
-
const basePath = new URL(baseUrl).pathname.replace(/\/$/, "");
|
|
283
|
-
if (basePath && pathname.startsWith(basePath)) return pathname.slice(basePath.length) || "/";
|
|
284
|
-
} catch {
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return pathname;
|
|
288
|
-
} catch {
|
|
289
|
-
return raw.split("?")[0];
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
function pathTemplateToRegex(base, template) {
|
|
293
|
-
const combined = (base + template).replace(/\/+/g, "/");
|
|
294
|
-
const pattern = combined.replace(/\{[^}]+\}/g, "[^/]+");
|
|
295
|
-
return new RegExp("^" + pattern + "/?$");
|
|
296
|
-
}
|
|
297
|
-
function findOperation(spec, method, reqUrl, requestBaseUrl) {
|
|
298
|
-
const bases = getServerBases(spec);
|
|
299
|
-
const pathname = urlPathname(reqUrl, requestBaseUrl);
|
|
300
|
-
const paths = spec["paths"] ?? {};
|
|
301
|
-
for (const [template, pathItem] of Object.entries(paths)) {
|
|
302
|
-
const resolved = resolveSchema(spec, pathItem);
|
|
303
|
-
for (const base of bases) {
|
|
304
|
-
if (pathTemplateToRegex(base, template).test(pathname)) {
|
|
305
|
-
const op = resolved[method.toLowerCase()];
|
|
306
|
-
return op ? { pathTemplate: template, operation: op } : null;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return null;
|
|
311
|
-
}
|
|
312
|
-
function validateRequestAgainstSpec(spec, req, envVars, requestBaseUrl) {
|
|
313
|
-
const violations = [];
|
|
314
|
-
const vars = envVars;
|
|
315
|
-
const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
|
316
|
-
const match = findOperation(spec, req.method, url, requestBaseUrl);
|
|
317
|
-
if (!match) {
|
|
318
|
-
violations.push({
|
|
319
|
-
type: "unknown_path",
|
|
320
|
-
message: `No operation found in spec for ${req.method} ${url}`
|
|
321
|
-
});
|
|
322
|
-
return violations;
|
|
323
|
-
}
|
|
324
|
-
const { operation } = match;
|
|
325
|
-
if (req.body.mode === "json" && req.body.json?.trim()) {
|
|
326
|
-
const requestBody = resolveSchema(spec, operation["requestBody"]);
|
|
327
|
-
const content = requestBody?.["content"] ?? {};
|
|
328
|
-
const jsonContent = content["application/json"];
|
|
329
|
-
if (jsonContent?.["schema"]) {
|
|
330
|
-
try {
|
|
331
|
-
const data = JSON.parse(authBuilder.interpolate(req.body.json, vars));
|
|
332
|
-
const schema = resolveSchema(spec, jsonContent["schema"]);
|
|
333
|
-
const validate = ajv.compile(schema);
|
|
334
|
-
if (!validate(data)) {
|
|
335
|
-
for (const err of validate.errors ?? []) {
|
|
336
|
-
violations.push({
|
|
337
|
-
type: "request_body_invalid",
|
|
338
|
-
message: err.message ?? "Request body schema violation",
|
|
339
|
-
path: err.instancePath || "/"
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
} catch (e) {
|
|
344
|
-
violations.push({
|
|
345
|
-
type: "request_body_invalid",
|
|
346
|
-
message: `Could not validate request body: ${e instanceof Error ? e.message : String(e)}`
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
const parameters = resolveSchema(spec, operation["parameters"] ?? []);
|
|
352
|
-
for (const param of parameters) {
|
|
353
|
-
const p = param;
|
|
354
|
-
if (p["in"] === "query" && p["required"] === true) {
|
|
355
|
-
const name = p["name"];
|
|
356
|
-
if (!req.params.some((kv) => kv.enabled && kv.key === name)) {
|
|
357
|
-
violations.push({
|
|
358
|
-
type: "request_body_invalid",
|
|
359
|
-
message: `Required query parameter "${name}" is missing`,
|
|
360
|
-
path: `query.${name}`
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return violations;
|
|
366
|
-
}
|
|
367
|
-
async function runProviderVerification(requests, envVars, specUrl, specPath, requestBaseUrl) {
|
|
368
|
-
const spec = await loadSpec(specUrl, specPath);
|
|
369
|
-
const start = Date.now();
|
|
370
|
-
const activeRequests = requests.filter((r) => !r.disabled);
|
|
371
|
-
const results = activeRequests.map((req) => {
|
|
372
|
-
const violations = validateRequestAgainstSpec(spec, req, envVars, requestBaseUrl);
|
|
373
|
-
const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => envVars[k] ?? `{{${k}}}`);
|
|
374
|
-
return {
|
|
375
|
-
requestId: req.id,
|
|
376
|
-
requestName: req.name,
|
|
377
|
-
method: req.method,
|
|
378
|
-
url,
|
|
379
|
-
passed: violations.length === 0,
|
|
380
|
-
violations
|
|
381
|
-
};
|
|
382
|
-
});
|
|
383
|
-
const passed = results.filter((r) => r.passed).length;
|
|
384
|
-
return {
|
|
385
|
-
mode: "provider",
|
|
386
|
-
total: results.length,
|
|
387
|
-
passed,
|
|
388
|
-
failed: results.length - passed,
|
|
389
|
-
results,
|
|
390
|
-
durationMs: Date.now() - start
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
function rebaseUrl(fullUrl, providerBaseUrl) {
|
|
394
|
-
if (!providerBaseUrl) return fullUrl;
|
|
395
|
-
try {
|
|
396
|
-
const orig = new URL(fullUrl, "http://placeholder.invalid");
|
|
397
|
-
const base = new URL(providerBaseUrl);
|
|
398
|
-
const basePath = base.pathname.replace(/\/$/, "");
|
|
399
|
-
base.pathname = (basePath + orig.pathname).replace(/\/{2,}/g, "/");
|
|
400
|
-
base.search = orig.search;
|
|
401
|
-
return base.toString();
|
|
402
|
-
} catch {
|
|
403
|
-
return fullUrl;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
async function setupState(stateHandlerUrl, state, action) {
|
|
407
|
-
if (!stateHandlerUrl) {
|
|
408
|
-
return {
|
|
409
|
-
type: "provider_state_failed",
|
|
410
|
-
message: `Interaction requires provider state "${state}" but no --states-url / stateHandlerUrl was configured`,
|
|
411
|
-
expected: state
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
try {
|
|
415
|
-
const resp = await undici.fetch(stateHandlerUrl, {
|
|
416
|
-
method: "POST",
|
|
417
|
-
headers: { "Content-Type": "application/json" },
|
|
418
|
-
body: JSON.stringify({ state, action })
|
|
419
|
-
});
|
|
420
|
-
if (!resp.ok) {
|
|
421
|
-
return {
|
|
422
|
-
type: "provider_state_failed",
|
|
423
|
-
message: `State handler returned HTTP ${resp.status} setting up "${state}"`,
|
|
424
|
-
expected: state,
|
|
425
|
-
actual: String(resp.status)
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
return null;
|
|
429
|
-
} catch (err) {
|
|
430
|
-
return {
|
|
431
|
-
type: "provider_state_failed",
|
|
432
|
-
message: `State handler unreachable for "${state}": ${err instanceof Error ? err.message : String(err)}`,
|
|
433
|
-
expected: state
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
async function verifyInteraction(req, vars, providerBaseUrl, stateHandlerUrl) {
|
|
438
|
-
const url = rebaseUrl(authBuilder.buildUrl(req.url, req.params, vars), providerBaseUrl);
|
|
439
|
-
const start = Date.now();
|
|
440
|
-
const violations = [];
|
|
441
|
-
const states = req.contract?.providerStates ?? [];
|
|
442
|
-
for (const state of states) {
|
|
443
|
-
const v = await setupState(stateHandlerUrl, state, "setup");
|
|
444
|
-
if (v) violations.push(v);
|
|
445
|
-
}
|
|
446
|
-
if (violations.length > 0) {
|
|
447
|
-
return { requestId: req.id, requestName: req.name, method: req.method, url, passed: false, violations, durationMs: Date.now() - start };
|
|
448
|
-
}
|
|
449
|
-
try {
|
|
450
|
-
const headers = new undici.Headers();
|
|
451
|
-
for (const h of req.headers) {
|
|
452
|
-
if (h.enabled && h.key) headers.set(authBuilder.interpolate(h.key, vars), authBuilder.interpolate(h.value, vars));
|
|
453
|
-
}
|
|
454
|
-
const authHeaders = await authBuilder.buildAuthHeaders(req.auth, vars);
|
|
455
|
-
for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
|
|
456
|
-
let body;
|
|
457
|
-
if (req.body.mode === "json" && req.body.json) {
|
|
458
|
-
body = authBuilder.interpolate(req.body.json, vars);
|
|
459
|
-
if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
|
|
460
|
-
} else if (req.body.mode === "raw" && req.body.raw) {
|
|
461
|
-
body = authBuilder.interpolate(req.body.raw, vars);
|
|
462
|
-
}
|
|
463
|
-
const resp = await undici.fetch(url, {
|
|
464
|
-
method: req.method,
|
|
465
|
-
headers,
|
|
466
|
-
body: !["GET", "HEAD"].includes(req.method) ? body : void 0
|
|
467
|
-
});
|
|
468
|
-
const bodyText = await resp.text();
|
|
469
|
-
const rawHeaders = {};
|
|
470
|
-
resp.headers.forEach((v, k) => {
|
|
471
|
-
rawHeaders[k] = v;
|
|
472
|
-
});
|
|
473
|
-
violations.push(...validateConsumerResponse(req.contract, resp.status, rawHeaders, bodyText));
|
|
474
|
-
return {
|
|
475
|
-
requestId: req.id,
|
|
476
|
-
requestName: req.name,
|
|
477
|
-
method: req.method,
|
|
478
|
-
url,
|
|
479
|
-
passed: violations.length === 0,
|
|
480
|
-
violations,
|
|
481
|
-
durationMs: Date.now() - start,
|
|
482
|
-
actualStatus: resp.status
|
|
483
|
-
};
|
|
484
|
-
} catch (err) {
|
|
485
|
-
violations.push({
|
|
486
|
-
type: "status_mismatch",
|
|
487
|
-
message: `Request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
488
|
-
});
|
|
489
|
-
return { requestId: req.id, requestName: req.name, method: req.method, url, passed: false, violations, durationMs: Date.now() - start };
|
|
490
|
-
} finally {
|
|
491
|
-
for (const state of states) await setupState(stateHandlerUrl, state, "teardown");
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
async function runLiveProviderVerification(requests, envVars, collectionVars = {}, providerBaseUrl, stateHandlerUrl) {
|
|
495
|
-
const vars = { ...envVars, ...collectionVars };
|
|
496
|
-
const start = Date.now();
|
|
497
|
-
const contractRequests = requests.filter((r) => !r.disabled && hasContract(r.contract));
|
|
498
|
-
const results = [];
|
|
499
|
-
for (const req of contractRequests) {
|
|
500
|
-
results.push(await verifyInteraction(req, vars, providerBaseUrl, stateHandlerUrl));
|
|
501
|
-
}
|
|
502
|
-
const passed = results.filter((r) => r.passed).length;
|
|
503
|
-
return {
|
|
504
|
-
mode: "provider-live",
|
|
505
|
-
total: results.length,
|
|
506
|
-
passed,
|
|
507
|
-
failed: results.length - passed,
|
|
508
|
-
results,
|
|
509
|
-
durationMs: Date.now() - start
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
function checkSchemaCompatibility(consumerSchema, providerSchema, path2 = "") {
|
|
513
|
-
const violations = [];
|
|
514
|
-
if (!consumerSchema || !providerSchema) return violations;
|
|
515
|
-
const cType = consumerSchema["type"];
|
|
516
|
-
const pType = providerSchema["type"];
|
|
517
|
-
if (cType && pType && cType !== pType) {
|
|
518
|
-
const ok = cType === "integer" && pType === "number" || cType === "number" && pType === "integer";
|
|
519
|
-
if (!ok) {
|
|
520
|
-
violations.push({
|
|
521
|
-
type: "schema_incompatible",
|
|
522
|
-
message: `Type mismatch${path2 ? ` at "${path2}"` : ""}: consumer expects "${cType}", provider offers "${pType}"`,
|
|
523
|
-
path: path2 || "/",
|
|
524
|
-
expected: cType,
|
|
525
|
-
actual: pType
|
|
526
|
-
});
|
|
527
|
-
return violations;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
if (cType === "array" || Array.isArray(consumerSchema["items"])) {
|
|
531
|
-
const cItems = consumerSchema["items"];
|
|
532
|
-
const pItems = providerSchema["items"];
|
|
533
|
-
if (cItems && pItems) {
|
|
534
|
-
violations.push(...checkSchemaCompatibility(cItems, pItems, path2 ? `${path2}[]` : "[]"));
|
|
535
|
-
}
|
|
536
|
-
return violations;
|
|
537
|
-
}
|
|
538
|
-
if (cType === "object" || consumerSchema["properties"]) {
|
|
539
|
-
const cProps = consumerSchema["properties"] ?? {};
|
|
540
|
-
const pProps = providerSchema["properties"] ?? {};
|
|
541
|
-
const cRequired = consumerSchema["required"] ?? [];
|
|
542
|
-
for (const field of cRequired) {
|
|
543
|
-
const fieldPath = path2 ? `${path2}.${field}` : field;
|
|
544
|
-
if (!(field in pProps)) {
|
|
545
|
-
violations.push({
|
|
546
|
-
type: "schema_incompatible",
|
|
547
|
-
message: `Consumer requires field "${fieldPath}" which is not defined in provider schema`,
|
|
548
|
-
path: fieldPath,
|
|
549
|
-
expected: "(defined)",
|
|
550
|
-
actual: "(absent)"
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
for (const [field, cPropSchema] of Object.entries(cProps)) {
|
|
555
|
-
if (field in pProps) {
|
|
556
|
-
const fieldPath = path2 ? `${path2}.${field}` : field;
|
|
557
|
-
violations.push(...checkSchemaCompatibility(
|
|
558
|
-
cPropSchema,
|
|
559
|
-
pProps[field],
|
|
560
|
-
fieldPath
|
|
561
|
-
));
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
return violations;
|
|
566
|
-
}
|
|
567
|
-
function getProviderResponseSchema(spec, req, envVars, statusCode, requestBaseUrl) {
|
|
568
|
-
const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => envVars[k] ?? `{{${k}}}`);
|
|
569
|
-
const match = findOperation(spec, req.method, url, requestBaseUrl);
|
|
570
|
-
if (!match) return null;
|
|
571
|
-
const responses = match.operation["responses"] ?? {};
|
|
572
|
-
const candidates = [String(statusCode), `${String(statusCode)[0]}xx`, "2XX", "2xx", "default"];
|
|
573
|
-
for (const candidate of candidates) {
|
|
574
|
-
const resp = responses[candidate];
|
|
575
|
-
if (resp) {
|
|
576
|
-
const resolved = resolveSchema(spec, resp);
|
|
577
|
-
const content = resolved["content"] ?? {};
|
|
578
|
-
const json = content["application/json"];
|
|
579
|
-
if (json?.["schema"]) {
|
|
580
|
-
return resolveSchema(spec, json["schema"]);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
return null;
|
|
585
|
-
}
|
|
586
|
-
async function executeRequest(req, vars) {
|
|
587
|
-
const url = authBuilder.buildUrl(req.url, req.params, vars);
|
|
588
|
-
const start = Date.now();
|
|
589
|
-
try {
|
|
590
|
-
const headers = new undici.Headers();
|
|
591
|
-
for (const h of req.headers) {
|
|
592
|
-
if (h.enabled && h.key) headers.set(authBuilder.interpolate(h.key, vars), authBuilder.interpolate(h.value, vars));
|
|
593
|
-
}
|
|
594
|
-
const authHeaders = await authBuilder.buildAuthHeaders(req.auth, vars);
|
|
595
|
-
for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
|
|
596
|
-
let body;
|
|
597
|
-
if (req.body.mode === "json" && req.body.json) {
|
|
598
|
-
body = authBuilder.interpolate(req.body.json, vars);
|
|
599
|
-
if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
|
|
600
|
-
}
|
|
601
|
-
const resp = await undici.fetch(url, {
|
|
602
|
-
method: req.method,
|
|
603
|
-
headers,
|
|
604
|
-
body: !["GET", "HEAD"].includes(req.method) ? body : void 0
|
|
605
|
-
});
|
|
606
|
-
const bodyText = await resp.text();
|
|
607
|
-
const rawHdrs = {};
|
|
608
|
-
resp.headers.forEach((v, k) => {
|
|
609
|
-
rawHdrs[k] = v;
|
|
610
|
-
});
|
|
611
|
-
return { status: resp.status, headers: rawHdrs, body: bodyText, durationMs: Date.now() - start };
|
|
612
|
-
} catch (err) {
|
|
613
|
-
return err instanceof Error ? err : new Error(String(err));
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
async function runBidirectional(requests, envVars, collectionVars = {}, specUrl, specPath, requestBaseUrl) {
|
|
617
|
-
const spec = await loadSpec(specUrl, specPath);
|
|
618
|
-
const vars = { ...envVars, ...collectionVars };
|
|
619
|
-
const start = Date.now();
|
|
620
|
-
const contractRequests = requests.filter((r) => !r.disabled && hasContract(r.contract));
|
|
621
|
-
const results = await Promise.all(contractRequests.map(async (req) => {
|
|
622
|
-
const url = authBuilder.buildUrl(req.url, req.params, vars);
|
|
623
|
-
const violations = [];
|
|
624
|
-
const expectedStatus = req.contract.statusCode ?? 200;
|
|
625
|
-
const consumerSchema = req.contract.bodySchema ? (() => {
|
|
626
|
-
try {
|
|
627
|
-
return JSON.parse(req.contract.bodySchema);
|
|
628
|
-
} catch {
|
|
629
|
-
return null;
|
|
630
|
-
}
|
|
631
|
-
})() : null;
|
|
632
|
-
if (consumerSchema) {
|
|
633
|
-
const providerSchema = getProviderResponseSchema(spec, req, vars, expectedStatus, requestBaseUrl);
|
|
634
|
-
if (!providerSchema) {
|
|
635
|
-
violations.push({
|
|
636
|
-
type: "schema_incompatible",
|
|
637
|
-
message: `No response schema found in spec for ${req.method} ${url} → ${expectedStatus}`
|
|
638
|
-
});
|
|
639
|
-
} else {
|
|
640
|
-
violations.push(...checkSchemaCompatibility(consumerSchema, providerSchema));
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
const result = await executeRequest(req, vars);
|
|
644
|
-
if (result instanceof Error) {
|
|
645
|
-
violations.push({ type: "status_mismatch", message: `Request failed: ${result.message}` });
|
|
646
|
-
return { requestId: req.id, requestName: req.name, method: req.method, url, passed: false, violations };
|
|
647
|
-
}
|
|
648
|
-
const liveViolations = validateConsumerResponse(
|
|
649
|
-
req.contract,
|
|
650
|
-
result.status,
|
|
651
|
-
result.headers,
|
|
652
|
-
result.body
|
|
653
|
-
);
|
|
654
|
-
violations.push(...liveViolations);
|
|
655
|
-
return {
|
|
656
|
-
requestId: req.id,
|
|
657
|
-
requestName: req.name,
|
|
658
|
-
method: req.method,
|
|
659
|
-
url,
|
|
660
|
-
passed: violations.length === 0,
|
|
661
|
-
violations,
|
|
662
|
-
durationMs: result.durationMs,
|
|
663
|
-
actualStatus: result.status
|
|
664
|
-
};
|
|
665
|
-
}));
|
|
666
|
-
const passed = results.filter((r) => r.passed).length;
|
|
667
|
-
return {
|
|
668
|
-
mode: "bidirectional",
|
|
669
|
-
total: results.length,
|
|
670
|
-
passed,
|
|
671
|
-
failed: results.length - passed,
|
|
672
|
-
results,
|
|
673
|
-
durationMs: Date.now() - start
|
|
674
|
-
};
|
|
675
|
-
}
|
|
676
|
-
function esc(s) {
|
|
677
|
-
return String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
678
|
-
}
|
|
679
|
-
function modeLabel(mode) {
|
|
680
|
-
if (mode === "bidirectional") return "Bi-directional";
|
|
681
|
-
if (mode === "provider-live") return "Provider (live)";
|
|
682
|
-
return mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
683
|
-
}
|
|
684
|
-
const STYLE = `
|
|
685
|
-
:root { color-scheme: dark; }
|
|
686
|
-
* { box-sizing: border-box; }
|
|
687
|
-
body { margin: 0; font: 14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
688
|
-
background: #0f1115; color: #e5e7eb; }
|
|
689
|
-
.wrap { max-width: 960px; margin: 0 auto; padding: 32px 24px 64px; }
|
|
690
|
-
h1 { font-size: 20px; margin: 0 0 4px; }
|
|
691
|
-
.sub { color: #8b929e; font-size: 13px; margin: 0 0 24px; }
|
|
692
|
-
.meta { display: flex; flex-wrap: wrap; gap: 8px 20px; color: #9aa1ad; font-size: 12px; margin-bottom: 12px; }
|
|
693
|
-
.meta b { color: #cbd2dc; font-weight: 600; }
|
|
694
|
-
.headline { font-size: 24px; font-weight: 700; }
|
|
695
|
-
.headline.ok { color: #34d399; } .headline.bad { color: #f87171; }
|
|
696
|
-
.bar { display: flex; height: 10px; border-radius: 6px; overflow: hidden; background: #1b1f27; margin: 14px 0 6px; }
|
|
697
|
-
.bar > i { display: block; height: 100%; }
|
|
698
|
-
.bar .ok { background: #34d399; } .bar .bad { background: #f87171; }
|
|
699
|
-
.cards { display: flex; flex-direction: column; gap: 10px; margin-top: 24px; }
|
|
700
|
-
.card { border: 1px solid #232834; border-radius: 10px; overflow: hidden; background: #141821; }
|
|
701
|
-
.card.fail { border-color: #7f1d1d; }
|
|
702
|
-
.row { display: flex; align-items: center; gap: 12px; padding: 12px 16px; }
|
|
703
|
-
.card.fail summary .row { background: rgba(127,29,29,.18); }
|
|
704
|
-
.pill { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 5px; flex-shrink: 0; letter-spacing: .03em; }
|
|
705
|
-
.pill.ok { background: rgba(16,84,55,.55); color: #34d399; }
|
|
706
|
-
.pill.bad { background: rgba(127,29,29,.55); color: #f87171; }
|
|
707
|
-
.method { font: 700 12px ui-monospace,SFMono-Regular,Menlo,monospace; width: 56px; flex-shrink: 0; color: #93c5fd; }
|
|
708
|
-
.name { flex: 1; color: #f3f4f6; font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
709
|
-
.url { color: #6b7280; font: 11px ui-monospace,monospace; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
710
|
-
.dur { color: #6b7280; font-size: 11px; }
|
|
711
|
-
.code { font: 700 12px ui-monospace,monospace; }
|
|
712
|
-
.code.s2 { color: #34d399; } .code.s3 { color: #fbbf24; } .code.s4, .code.s5 { color: #f87171; }
|
|
713
|
-
.viol { padding: 14px 16px; border-top: 1px solid #1f2530; display: flex; flex-direction: column; gap: 10px; }
|
|
714
|
-
.v { border-left: 2px solid #dc2626; background: rgba(127,29,29,.12); border-radius: 0 6px 6px 0; padding: 8px 12px; }
|
|
715
|
-
.v .t { font: 700 10px ui-monospace,monospace; color: #f87171; text-transform: uppercase; letter-spacing: .04em; }
|
|
716
|
-
.v .path { font: 10px ui-monospace,monospace; color: #8b929e; background: #1b1f27; padding: 1px 6px; border-radius: 4px; margin-left: 8px; }
|
|
717
|
-
.v .m { color: #fecaca; font-size: 13px; margin: 4px 0 0; }
|
|
718
|
-
.v .ea { font: 11px ui-monospace,monospace; margin-top: 4px; display: flex; gap: 18px; }
|
|
719
|
-
.v .ea .lab { color: #6b7280; }
|
|
720
|
-
.v .ea .exp { color: #34d399; } .v .ea .act { color: #f87171; }
|
|
721
|
-
.pass-note { color: #34d399; font-size: 13px; padding: 12px 16px; border-top: 1px solid #1f2530; }
|
|
722
|
-
table { border-collapse: collapse; width: 100%; margin-top: 16px; font-size: 13px; }
|
|
723
|
-
th, td { border: 1px solid #232834; padding: 8px 12px; text-align: left; }
|
|
724
|
-
th { background: #141821; color: #cbd2dc; font-weight: 600; }
|
|
725
|
-
td.cell { text-align: center; }
|
|
726
|
-
.b { display: inline-block; min-width: 56px; padding: 2px 8px; border-radius: 5px; font-size: 11px; font-weight: 700; }
|
|
727
|
-
.b.ok { background: rgba(16,84,55,.55); color: #34d399; }
|
|
728
|
-
.b.bad { background: rgba(127,29,29,.55); color: #f87171; }
|
|
729
|
-
.b.na { background: #1b1f27; color: #6b7280; }
|
|
730
|
-
.foot { color: #6b7280; font-size: 11px; margin-top: 40px; text-align: center; }
|
|
731
|
-
summary { cursor: pointer; list-style: none; }
|
|
732
|
-
summary::-webkit-details-marker { display: none; }
|
|
733
|
-
`;
|
|
734
|
-
function page(title, body) {
|
|
735
|
-
return `<!doctype html>
|
|
736
|
-
<html lang="en"><head><meta charset="utf-8">
|
|
737
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
738
|
-
<title>${esc(title)}</title>
|
|
739
|
-
<style>${STYLE}</style>
|
|
740
|
-
</head><body><div class="wrap">${body}
|
|
741
|
-
<p class="foot">Generated by API Spector · contract reporting</p>
|
|
742
|
-
</div></body></html>`;
|
|
743
|
-
}
|
|
744
|
-
function statusClass(code) {
|
|
745
|
-
if (code === void 0) return "";
|
|
746
|
-
return "s" + String(code)[0];
|
|
747
|
-
}
|
|
748
|
-
function violationHtml(r) {
|
|
749
|
-
if (r.violations.length === 0) return `<div class="pass-note">All expectations met.</div>`;
|
|
750
|
-
const items = r.violations.map((v) => {
|
|
751
|
-
const path2 = v.path ? `<span class="path">${esc(v.path)}</span>` : "";
|
|
752
|
-
const ea = v.expected || v.actual ? `<div class="ea">${v.expected ? `<span><span class="lab">expected </span><span class="exp">${esc(v.expected)}</span></span>` : ""}${v.actual ? `<span><span class="lab">actual </span><span class="act">${esc(v.actual)}</span></span>` : ""}</div>` : "";
|
|
753
|
-
return `<div class="v"><div><span class="t">${esc(v.type.replace(/_/g, " "))}</span>${path2}</div><p class="m">${esc(v.message)}</p>${ea}</div>`;
|
|
754
|
-
}).join("");
|
|
755
|
-
return `<div class="viol">${items}</div>`;
|
|
756
|
-
}
|
|
757
|
-
function cardHtml(r) {
|
|
758
|
-
const pill = r.passed ? `<span class="pill ok">PASS</span>` : `<span class="pill bad">FAIL</span>`;
|
|
759
|
-
const status = r.actualStatus !== void 0 ? `<span class="code ${statusClass(r.actualStatus)}">${r.actualStatus}</span>` : "";
|
|
760
|
-
const dur = r.durationMs !== void 0 ? `<span class="dur">${r.durationMs}ms</span>` : "";
|
|
761
|
-
return `<details class="card ${r.passed ? "" : "fail"}" ${r.passed ? "" : "open"}>
|
|
762
|
-
<summary><div class="row">${pill}<span class="method">${esc(r.method)}</span><span class="name">${esc(r.requestName)}</span><span class="url">${esc(r.url)}</span>${status}${dur}</div></summary>
|
|
763
|
-
${violationHtml(r)}
|
|
764
|
-
</details>`;
|
|
765
|
-
}
|
|
766
|
-
function reportToHtml(report, meta = {}) {
|
|
767
|
-
const okPct = report.total ? Math.round(report.passed / report.total * 100) : 100;
|
|
768
|
-
const badPct = 100 - okPct;
|
|
769
|
-
const headlineCls = report.failed === 0 ? "ok" : "bad";
|
|
770
|
-
const headline = report.failed === 0 ? "✓ All passed" : `✗ ${report.failed} failed`;
|
|
771
|
-
const metaRows = [
|
|
772
|
-
`<span><b>Mode</b> ${esc(modeLabel(report.mode))}</span>`,
|
|
773
|
-
meta.provider ? `<span><b>Provider</b> ${esc(meta.provider)}</span>` : "",
|
|
774
|
-
meta.consumer ? `<span><b>Consumer</b> ${esc(meta.consumer)}</span>` : "",
|
|
775
|
-
meta.spec ? `<span><b>Spec</b> ${esc(meta.spec)}</span>` : "",
|
|
776
|
-
`<span><b>Duration</b> ${report.durationMs}ms</span>`,
|
|
777
|
-
meta.generatedAt ? `<span><b>Generated</b> ${esc(meta.generatedAt)}</span>` : ""
|
|
778
|
-
].filter(Boolean).join("");
|
|
779
|
-
const failed = report.results.filter((r) => !r.passed);
|
|
780
|
-
const passed = report.results.filter((r) => r.passed);
|
|
781
|
-
const cards = [...failed, ...passed].map(cardHtml).join("");
|
|
782
|
-
const body = `
|
|
783
|
-
<h1>${esc(meta.title ?? "Contract Verification Report")}</h1>
|
|
784
|
-
<p class="sub"><span class="headline ${headlineCls}">${esc(headline)}</span> ${report.passed} / ${report.total} interactions passed</p>
|
|
785
|
-
<div class="meta">${metaRows}</div>
|
|
786
|
-
<div class="bar"><i class="ok" style="width:${okPct}%"></i><i class="bad" style="width:${badPct}%"></i></div>
|
|
787
|
-
<div class="cards">${cards || '<p class="sub">No interactions ran.</p>'}</div>`;
|
|
788
|
-
return page(meta.title ?? "Contract Verification Report", body);
|
|
789
|
-
}
|
|
790
|
-
function dashboardToHtml(records, generatedAt) {
|
|
791
|
-
const pacticipants = [...new Set(records.map((r) => r.pacticipant))].sort();
|
|
792
|
-
const versions = [...new Set(records.map((r) => r.version))].sort();
|
|
793
|
-
const byKey = new Map(records.map((r) => [`${r.pacticipant}@@${r.version}`, r]));
|
|
794
|
-
const header = `<tr><th>Pacticipant \\ Version</th>${versions.map((v) => `<th>${esc(v)}</th>`).join("")}</tr>`;
|
|
795
|
-
const rows = pacticipants.map((p) => {
|
|
796
|
-
const cells = versions.map((v) => {
|
|
797
|
-
const rec = byKey.get(`${p}@@${v}`);
|
|
798
|
-
if (!rec) return `<td class="cell"><span class="b na">—</span></td>`;
|
|
799
|
-
const cls = rec.passed ? "ok" : "bad";
|
|
800
|
-
const label = rec.passed ? `${rec.report.total}/${rec.report.total}` : `${rec.report.passed}/${rec.report.total}`;
|
|
801
|
-
return `<td class="cell" title="${esc(rec.recordedAt)}"><span class="b ${cls}">${esc(label)}</span></td>`;
|
|
802
|
-
}).join("");
|
|
803
|
-
return `<tr><td><b>${esc(p)}</b></td>${cells}</tr>`;
|
|
804
|
-
}).join("");
|
|
805
|
-
const totalPass = records.filter((r) => r.passed).length;
|
|
806
|
-
const body = `
|
|
807
|
-
<h1>Contract Dashboard</h1>
|
|
808
|
-
<p class="sub">${records.length} recorded verification${records.length === 1 ? "" : "s"} · ${totalPass} passing</p>
|
|
809
|
-
<div class="meta">${generatedAt ? `<span><b>Generated</b> ${esc(generatedAt)}</span>` : ""}</div>
|
|
810
|
-
${records.length ? `<table>${header}${rows}</table>` : '<p class="sub">No recorded results yet. Run a verification with <code>--record --app-version <ver></code>.</p>'}`;
|
|
811
|
-
return page("Contract Dashboard", body);
|
|
812
|
-
}
|
|
813
|
-
const SNAPSHOT_DIR = "contracts";
|
|
814
|
-
function safeName(raw) {
|
|
815
|
-
return raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "spec";
|
|
816
|
-
}
|
|
817
|
-
function sha256(text) {
|
|
818
|
-
return crypto.createHash("sha256").update(text).digest("hex");
|
|
819
|
-
}
|
|
820
|
-
function detectFormat(source, contentType) {
|
|
821
|
-
const lc = source.toLowerCase();
|
|
822
|
-
if (lc.endsWith(".yaml") || lc.endsWith(".yml")) return "yaml";
|
|
823
|
-
if (lc.endsWith(".json")) return "json";
|
|
824
|
-
if (contentType.includes("yaml")) return "yaml";
|
|
825
|
-
return "json";
|
|
826
|
-
}
|
|
827
|
-
function tryExtractSpecVersion(specText, format) {
|
|
828
|
-
try {
|
|
829
|
-
const parsed = format === "yaml" ? jsYaml.load(specText) : JSON.parse(specText);
|
|
830
|
-
const info = parsed?.info;
|
|
831
|
-
if (info && typeof info.version === "string") return info.version;
|
|
832
|
-
} catch {
|
|
833
|
-
}
|
|
834
|
-
return void 0;
|
|
835
|
-
}
|
|
836
|
-
async function captureSnapshot(workspaceDir, opts) {
|
|
837
|
-
const { specUrl, specPath } = opts;
|
|
838
|
-
if (!specUrl && !specPath) throw new Error("captureSnapshot: specUrl or specPath is required");
|
|
839
|
-
let specText;
|
|
840
|
-
let format;
|
|
841
|
-
let source;
|
|
842
|
-
if (specUrl) {
|
|
843
|
-
const resp = await undici.fetch(specUrl);
|
|
844
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status} loading spec from ${specUrl}`);
|
|
845
|
-
specText = await resp.text();
|
|
846
|
-
format = detectFormat(specUrl, resp.headers.get("content-type") ?? "");
|
|
847
|
-
source = specUrl;
|
|
848
|
-
} else {
|
|
849
|
-
specText = await promises.readFile(specPath, "utf8");
|
|
850
|
-
format = detectFormat(specPath, "");
|
|
851
|
-
source = specPath;
|
|
852
|
-
}
|
|
853
|
-
const id = crypto.randomUUID();
|
|
854
|
-
const specVersion = tryExtractSpecVersion(specText, format);
|
|
855
|
-
let name = opts.name?.trim();
|
|
856
|
-
if (!name) {
|
|
857
|
-
try {
|
|
858
|
-
name = new URL(source).hostname;
|
|
859
|
-
} catch {
|
|
860
|
-
name = source.split(/[\\/]/).pop() ?? "spec";
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
if (specVersion && !name.includes(specVersion)) name = `${name} ${specVersion}`;
|
|
864
|
-
const snapshot = {
|
|
865
|
-
version: "1.0",
|
|
866
|
-
id,
|
|
867
|
-
name,
|
|
868
|
-
source,
|
|
869
|
-
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
870
|
-
format,
|
|
871
|
-
specVersion,
|
|
872
|
-
spec: specText,
|
|
873
|
-
sha256: sha256(specText)
|
|
874
|
-
};
|
|
875
|
-
const relPath = path.join(SNAPSHOT_DIR, `${safeName(name)}-${id.slice(0, 8)}.contract.json`);
|
|
876
|
-
const absPath = path.resolve(workspaceDir, relPath);
|
|
877
|
-
await promises.mkdir(path.join(workspaceDir, SNAPSHOT_DIR), { recursive: true });
|
|
878
|
-
await promises.writeFile(absPath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
879
|
-
Object.defineProperty(snapshot, "__relPath", { value: relPath, enumerable: false });
|
|
880
|
-
return snapshot;
|
|
881
|
-
}
|
|
882
|
-
function relPathOf(snapshot) {
|
|
883
|
-
const hidden = snapshot.__relPath;
|
|
884
|
-
return hidden;
|
|
885
|
-
}
|
|
886
|
-
async function loadSnapshot(workspaceDir, relPath) {
|
|
887
|
-
const raw = await promises.readFile(path.resolve(workspaceDir, relPath), "utf8");
|
|
888
|
-
return JSON.parse(raw);
|
|
889
|
-
}
|
|
890
|
-
async function listSnapshots(workspaceDir, registered = []) {
|
|
891
|
-
const seen = new Set(registered);
|
|
892
|
-
try {
|
|
893
|
-
const entries = await promises.readdir(path.join(workspaceDir, SNAPSHOT_DIR));
|
|
894
|
-
for (const f of entries) {
|
|
895
|
-
if (f.endsWith(".contract.json")) seen.add(path.join(SNAPSHOT_DIR, f));
|
|
896
|
-
}
|
|
897
|
-
} catch {
|
|
898
|
-
}
|
|
899
|
-
const out = [];
|
|
900
|
-
for (const relPath of seen) {
|
|
901
|
-
try {
|
|
902
|
-
const snapshot = await loadSnapshot(workspaceDir, relPath);
|
|
903
|
-
out.push({ relPath, snapshot });
|
|
904
|
-
} catch {
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
out.sort((a, b) => b.snapshot.capturedAt.localeCompare(a.snapshot.capturedAt));
|
|
908
|
-
return out;
|
|
909
|
-
}
|
|
910
|
-
async function deleteSnapshot(workspaceDir, relPath) {
|
|
911
|
-
try {
|
|
912
|
-
await promises.unlink(path.resolve(workspaceDir, relPath));
|
|
913
|
-
} catch {
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
exports.MATCH_KEY = MATCH_KEY;
|
|
917
|
-
exports.captureSnapshot = captureSnapshot;
|
|
918
|
-
exports.dashboardToHtml = dashboardToHtml;
|
|
919
|
-
exports.deleteSnapshot = deleteSnapshot;
|
|
920
|
-
exports.hasContract = hasContract;
|
|
921
|
-
exports.listSnapshots = listSnapshots;
|
|
922
|
-
exports.loadSnapshot = loadSnapshot;
|
|
923
|
-
exports.relPathOf = relPathOf;
|
|
924
|
-
exports.reportToHtml = reportToHtml;
|
|
925
|
-
exports.runBidirectional = runBidirectional;
|
|
926
|
-
exports.runConsumerContracts = runConsumerContracts;
|
|
927
|
-
exports.runLiveProviderVerification = runLiveProviderVerification;
|
|
928
|
-
exports.runProviderVerification = runProviderVerification;
|