@testsmith/api-spector 0.4.9 → 0.5.0
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 +6 -0
- package/out/main/chunks/coverage-C54mZEh_.js +146 -0
- package/out/main/chunks/{handle-CF2LjTGV.js → handle-b6bp3fb0.js} +18 -0
- package/out/main/chunks/{import-ChCkdHOB.js → import-BeY7gjWL.js} +2 -2
- package/out/main/chunks/{request-exec-DeXgxps8.js → request-exec-D_xyyt3_.js} +6 -6
- package/out/main/chunks/{snapshots-8wLrfQcq.js → snapshots-BJiZNuRN.js} +1 -1
- package/out/main/chunks/{soap-handler-pOrJ625E.js → soap-handler-BYkE6MyE.js} +2 -2
- package/out/main/compare.js +218 -0
- package/out/main/contract.js +3 -3
- package/out/main/coverage.js +161 -0
- package/out/main/generate-tests.js +317 -0
- package/out/main/index.js +213 -20
- package/out/main/lib.js +2 -2
- package/out/main/runner.js +3 -3
- package/out/main/wsdl.js +3 -3
- package/out/preload/index.js +34 -0
- package/out/renderer/assets/index-Cgk7l8OV.css +2 -0
- package/out/renderer/assets/{index-b63nUtxA.js → index-Cj7BRfK2.js} +1362 -162
- package/out/renderer/index.html +2 -2
- package/package.json +3 -1
- package/out/renderer/assets/index-CFvibipL.css +0 -2
package/bin/cli.js
CHANGED
|
@@ -22,6 +22,9 @@ const COMMANDS = {
|
|
|
22
22
|
record: { entrypoint: 'record.js', runner: 'node' },
|
|
23
23
|
agents: { entrypoint: 'agents.js', runner: 'node' },
|
|
24
24
|
contract: { entrypoint: 'contract.js',runner: 'node' },
|
|
25
|
+
coverage: { entrypoint: 'coverage.js',runner: 'node' },
|
|
26
|
+
'generate-tests': { entrypoint: 'generate-tests.js', runner: 'node' },
|
|
27
|
+
compare: { entrypoint: 'compare.js', runner: 'node' },
|
|
25
28
|
wsdl: { entrypoint: 'wsdl.js', runner: 'node' },
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -35,6 +38,9 @@ function printHelp() {
|
|
|
35
38
|
console.log(' api-spector mock --workspace <path> Start mock servers from CLI')
|
|
36
39
|
console.log(' api-spector record --upstream <url> Record API traffic as mock stubs')
|
|
37
40
|
console.log(' api-spector contract list|run Manage & run pinned contract snapshots')
|
|
41
|
+
console.log(' api-spector coverage --spec <file> Measure OpenAPI test coverage')
|
|
42
|
+
console.log(' api-spector generate-tests --spec <file> Generate tests from an OpenAPI spec')
|
|
43
|
+
console.log(' api-spector compare <old> <new> Diff specs; find breaking changes + impact')
|
|
38
44
|
console.log(' api-spector wsdl describe|import-* Inspect a WSDL or import as collection/mock')
|
|
39
45
|
console.log('')
|
|
40
46
|
console.log(' Options:')
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
3
|
+
function normalizePath(url) {
|
|
4
|
+
let u = (url || "").trim();
|
|
5
|
+
u = u.split("#")[0].split("?")[0];
|
|
6
|
+
u = u.replace(/\{\{[^}]*\}\}/g, "");
|
|
7
|
+
u = u.replace(/^[a-z0-9+.-]+:\/\/[^/]*/i, "");
|
|
8
|
+
u = u.replace(/\/{2,}/g, "/");
|
|
9
|
+
if (!u.startsWith("/")) u = "/" + u;
|
|
10
|
+
u = u.replace(/\/+$/, "");
|
|
11
|
+
return u === "" ? "/" : u;
|
|
12
|
+
}
|
|
13
|
+
function segments(path) {
|
|
14
|
+
return path.split("/").filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
function isParam(seg) {
|
|
17
|
+
return seg.startsWith("{") && seg.endsWith("}");
|
|
18
|
+
}
|
|
19
|
+
function pathMatches(requestUrl, template) {
|
|
20
|
+
const reqSegs = segments(normalizePath(requestUrl));
|
|
21
|
+
const tplSegs = segments(template);
|
|
22
|
+
if (reqSegs.length < tplSegs.length) return false;
|
|
23
|
+
const tail = reqSegs.slice(reqSegs.length - tplSegs.length);
|
|
24
|
+
return tplSegs.every((seg, i) => isParam(seg) ? tail[i] !== void 0 && tail[i] !== "" : tail[i] === seg);
|
|
25
|
+
}
|
|
26
|
+
function resolveRef(spec, ref) {
|
|
27
|
+
const parts = ref.replace(/^#\//, "").split("/");
|
|
28
|
+
return parts.reduce((o, k) => o?.[decodeURIComponent(k.replace(/~1/g, "/").replace(/~0/g, "~"))], spec);
|
|
29
|
+
}
|
|
30
|
+
function deref(spec, node, depth = 0, seen = /* @__PURE__ */ new Set()) {
|
|
31
|
+
if (!node || typeof node !== "object" || depth > 8) return node;
|
|
32
|
+
if (Array.isArray(node)) return node.map((n) => deref(spec, n, depth + 1, seen));
|
|
33
|
+
if ("$ref" in node) {
|
|
34
|
+
const target = resolveRef(spec, node.$ref);
|
|
35
|
+
if (!target || seen.has(target)) return {};
|
|
36
|
+
return deref(spec, target, depth + 1, /* @__PURE__ */ new Set([...seen, target]));
|
|
37
|
+
}
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const [k, v] of Object.entries(node)) out[k] = deref(spec, v, depth + 1, seen);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function flattenSchemaPaths(schema, prefix = "", depth = 0) {
|
|
43
|
+
if (!schema || typeof schema !== "object" || depth > 8) return [];
|
|
44
|
+
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
|
|
45
|
+
const out = [];
|
|
46
|
+
if (type === "object" || schema.properties) {
|
|
47
|
+
for (const [name, sub] of Object.entries(schema.properties ?? {})) {
|
|
48
|
+
const path = prefix ? `${prefix}.${name}` : name;
|
|
49
|
+
out.push(path);
|
|
50
|
+
out.push(...flattenSchemaPaths(sub, path, depth + 1));
|
|
51
|
+
}
|
|
52
|
+
} else if (type === "array" && schema.items) {
|
|
53
|
+
out.push(...flattenSchemaPaths(schema.items, `${prefix}[]`, depth + 1));
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function flattenValuePaths(value, prefix = "", depth = 0) {
|
|
58
|
+
if (value == null || typeof value !== "object" || depth > 8) return [];
|
|
59
|
+
const out = [];
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
for (const el of value) out.push(...flattenValuePaths(el, `${prefix}[]`, depth + 1));
|
|
62
|
+
} else {
|
|
63
|
+
for (const [k, v] of Object.entries(value)) {
|
|
64
|
+
const path = prefix ? `${prefix}.${k}` : k;
|
|
65
|
+
out.push(path);
|
|
66
|
+
out.push(...flattenValuePaths(v, path, depth + 1));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...new Set(out)];
|
|
70
|
+
}
|
|
71
|
+
function successResponseSchema(spec, responses) {
|
|
72
|
+
const code = Object.keys(responses ?? {}).filter((c) => /^2\d\d$/.test(c)).sort()[0];
|
|
73
|
+
if (!code) return void 0;
|
|
74
|
+
const schema = responses[code]?.content?.["application/json"]?.schema ?? responses[code]?.content?.["application/json;charset=utf-8"]?.schema;
|
|
75
|
+
return schema ? deref(spec, schema) : void 0;
|
|
76
|
+
}
|
|
77
|
+
function enumerateOperations(spec) {
|
|
78
|
+
const doc = spec ?? {};
|
|
79
|
+
const ops = [];
|
|
80
|
+
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
81
|
+
if (!item || typeof item !== "object") continue;
|
|
82
|
+
for (const [method, op] of Object.entries(item)) {
|
|
83
|
+
if (!HTTP_METHODS.includes(method.toLowerCase())) continue;
|
|
84
|
+
if (!op || typeof op !== "object") continue;
|
|
85
|
+
const declaredStatuses = Object.keys(op.responses ?? {}).filter((c) => /^\d{3}$/.test(c));
|
|
86
|
+
ops.push({ method: method.toUpperCase(), path, operationId: op.operationId, declaredStatuses, responses: op.responses });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return ops;
|
|
90
|
+
}
|
|
91
|
+
function round(n) {
|
|
92
|
+
return Math.round(n * 10) / 10;
|
|
93
|
+
}
|
|
94
|
+
function computeCoverage(spec, requests, observations = []) {
|
|
95
|
+
const doc = spec ?? {};
|
|
96
|
+
const operations = enumerateOperations(spec).map((op) => {
|
|
97
|
+
const mapped = requests.filter((r) => r.method.toUpperCase() === op.method && pathMatches(r.url, op.path));
|
|
98
|
+
const obs = observations.filter((o) => o.method.toUpperCase() === op.method && pathMatches(o.url, op.path));
|
|
99
|
+
const asserted = mapped.map((r) => r.expectedStatus).filter((s) => typeof s === "number");
|
|
100
|
+
const seen = obs.map((o) => o.status);
|
|
101
|
+
const covered = /* @__PURE__ */ new Set([...asserted, ...seen]);
|
|
102
|
+
const coveredStatuses2 = op.declaredStatuses.filter((code) => covered.has(Number(code)));
|
|
103
|
+
const hasNegativeTest = [...covered].some((s) => s >= 400);
|
|
104
|
+
const declaredProperties2 = flattenSchemaPaths(successResponseSchema(doc, op.responses ?? {}));
|
|
105
|
+
const observedPaths = new Set(obs.flatMap((o) => o.responsePaths ?? []));
|
|
106
|
+
const coveredProperties2 = declaredProperties2.filter((p) => observedPaths.has(p));
|
|
107
|
+
return {
|
|
108
|
+
method: op.method,
|
|
109
|
+
path: op.path,
|
|
110
|
+
operationId: op.operationId,
|
|
111
|
+
tested: mapped.length > 0 || obs.length > 0,
|
|
112
|
+
requests: mapped.map((r) => r.name),
|
|
113
|
+
declaredStatuses: op.declaredStatuses,
|
|
114
|
+
coveredStatuses: coveredStatuses2,
|
|
115
|
+
hasNegativeTest,
|
|
116
|
+
declaredProperties: declaredProperties2,
|
|
117
|
+
coveredProperties: coveredProperties2
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
const tested = operations.filter((o) => o.tested).length;
|
|
121
|
+
const declaredStatuses = operations.reduce((n, o) => n + o.declaredStatuses.length, 0);
|
|
122
|
+
const coveredStatuses = operations.reduce((n, o) => n + o.coveredStatuses.length, 0);
|
|
123
|
+
const declaredProperties = operations.reduce((n, o) => n + o.declaredProperties.length, 0);
|
|
124
|
+
const coveredProperties = operations.reduce((n, o) => n + o.coveredProperties.length, 0);
|
|
125
|
+
const withoutNegativeTest = operations.filter((o) => o.tested && !o.hasNegativeTest).length;
|
|
126
|
+
return {
|
|
127
|
+
spec: { title: doc.info?.title, version: doc.info?.version },
|
|
128
|
+
totals: {
|
|
129
|
+
operations: operations.length,
|
|
130
|
+
tested,
|
|
131
|
+
untested: operations.length - tested,
|
|
132
|
+
operationPct: operations.length ? round(tested / operations.length * 100) : 0,
|
|
133
|
+
declaredStatuses,
|
|
134
|
+
coveredStatuses,
|
|
135
|
+
statusPct: declaredStatuses ? round(coveredStatuses / declaredStatuses * 100) : 0,
|
|
136
|
+
withoutNegativeTest,
|
|
137
|
+
declaredProperties,
|
|
138
|
+
coveredProperties,
|
|
139
|
+
propertyPct: declaredProperties ? round(coveredProperties / declaredProperties * 100) : 0
|
|
140
|
+
},
|
|
141
|
+
operations
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
exports.computeCoverage = computeCoverage;
|
|
145
|
+
exports.flattenValuePaths = flattenValuePaths;
|
|
146
|
+
exports.pathMatches = pathMatches;
|
|
@@ -104,10 +104,28 @@ const IPC = {
|
|
|
104
104
|
fetch: "wsdl:fetch",
|
|
105
105
|
import: "wsdl:import"
|
|
106
106
|
},
|
|
107
|
+
// ─── gRPC ──────────────────────────────────────────────────────────────────
|
|
108
|
+
grpc: {
|
|
109
|
+
/** Load a .proto (source or path) and enumerate its services/methods. */
|
|
110
|
+
loadProto: "grpc:loadProto",
|
|
111
|
+
/** Invoke a method (unary or server-streaming). Streams back messages. */
|
|
112
|
+
invoke: "grpc:invoke",
|
|
113
|
+
/** Cancel an in-flight call. */
|
|
114
|
+
cancel: "grpc:cancel",
|
|
115
|
+
/** Event: a received message frame. */
|
|
116
|
+
message: "grpc:message",
|
|
117
|
+
/** Event: call status change (running / completed / error, with code). */
|
|
118
|
+
status: "grpc:status"
|
|
119
|
+
},
|
|
107
120
|
// ─── Docs generation ───────────────────────────────────────────────────────
|
|
108
121
|
docs: {
|
|
109
122
|
generate: "docs:generate"
|
|
110
123
|
},
|
|
124
|
+
// ─── OpenAPI coverage ────────────────────────────────────────────────────────
|
|
125
|
+
coverage: {
|
|
126
|
+
/** Read + parse an OpenAPI spec (file path, URL, or raw text). */
|
|
127
|
+
loadSpec: "coverage:loadSpec"
|
|
128
|
+
},
|
|
111
129
|
// ─── Contract testing ──────────────────────────────────────────────────────
|
|
112
130
|
contract: {
|
|
113
131
|
run: "contract:run",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
const uuid = require("uuid");
|
|
4
|
-
const soapHandler = require("./soap-handler-
|
|
5
|
-
require("./handle-
|
|
4
|
+
const soapHandler = require("./soap-handler-BYkE6MyE.js");
|
|
5
|
+
require("./handle-b6bp3fb0.js");
|
|
6
6
|
require("https");
|
|
7
7
|
require("http");
|
|
8
8
|
require("@xmldom/xmldom");
|
|
@@ -24,10 +24,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
const undici = require("undici");
|
|
25
25
|
const promises = require("fs/promises");
|
|
26
26
|
const nodeTls = require("tls");
|
|
27
|
-
const handle = require("./handle-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
27
|
+
const handle = require("./handle-b6bp3fb0.js");
|
|
28
|
+
const fs = require("node:fs");
|
|
29
|
+
const os = require("node:os");
|
|
30
|
+
const path$1 = require("node:path");
|
|
31
31
|
const node_crypto = require("node:crypto");
|
|
32
32
|
const crypto = require("crypto");
|
|
33
33
|
const path = require("path");
|
|
@@ -124,7 +124,7 @@ function env$3(...names) {
|
|
|
124
124
|
}
|
|
125
125
|
function readFileTrim(path2) {
|
|
126
126
|
try {
|
|
127
|
-
return
|
|
127
|
+
return fs.readFileSync(path2, "utf8").trim() || void 0;
|
|
128
128
|
} catch {
|
|
129
129
|
return void 0;
|
|
130
130
|
}
|
|
@@ -135,7 +135,7 @@ function resolveConn$3() {
|
|
|
135
135
|
if (!address) {
|
|
136
136
|
throw new Error("Vault is not configured: set VAULT_ADDR (or settings.secrets.vault.address)");
|
|
137
137
|
}
|
|
138
|
-
const token = env$3("VAULT_TOKEN", "API_SPECTOR_VAULT_TOKEN") ?? readFileTrim(
|
|
138
|
+
const token = env$3("VAULT_TOKEN", "API_SPECTOR_VAULT_TOKEN") ?? readFileTrim(path$1.join(os.homedir(), ".vault-token"));
|
|
139
139
|
const roleId = env$3("VAULT_ROLE_ID", "API_SPECTOR_VAULT_ROLE_ID") ?? cfg.roleId;
|
|
140
140
|
const secretId = env$3("VAULT_SECRET_ID", "API_SPECTOR_VAULT_SECRET_ID");
|
|
141
141
|
const jwt = env$3("VAULT_JWT", "API_SPECTOR_VAULT_JWT") ?? (env$3("VAULT_JWT_PATH", "API_SPECTOR_VAULT_JWT_PATH") ? readFileTrim(env$3("VAULT_JWT_PATH", "API_SPECTOR_VAULT_JWT_PATH")) : void 0);
|
|
@@ -3,7 +3,7 @@ const promises = require("fs/promises");
|
|
|
3
3
|
const path = require("path");
|
|
4
4
|
const crypto = require("crypto");
|
|
5
5
|
const undici = require("undici");
|
|
6
|
-
const requestExec = require("./request-exec-
|
|
6
|
+
const requestExec = require("./request-exec-D_xyyt3_.js");
|
|
7
7
|
const Ajv = require("ajv");
|
|
8
8
|
const jsYaml = require("js-yaml");
|
|
9
9
|
require("http");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
const handle = require("./handle-
|
|
2
|
+
const handle = require("./handle-b6bp3fb0.js");
|
|
3
3
|
const https = require("https");
|
|
4
4
|
const http = require("http");
|
|
5
5
|
const xmldom = require("@xmldom/xmldom");
|
|
@@ -315,7 +315,7 @@ function registerSoapHandlers(ipc) {
|
|
|
315
315
|
handle.handleIpc(ipc, handle.IPC.wsdl.import, async (_event, opts) => {
|
|
316
316
|
const { validateWsdlImport } = await Promise.resolve().then(() => require("./ipc-validate-k6KI8adf.js"));
|
|
317
317
|
validateWsdlImport(opts);
|
|
318
|
-
const { importWsdl } = await Promise.resolve().then(() => require("./import-
|
|
318
|
+
const { importWsdl } = await Promise.resolve().then(() => require("./import-BeY7gjWL.js"));
|
|
319
319
|
const wsdlText = opts.xml ?? (opts.url ? await fetchUrl(opts.url) : "");
|
|
320
320
|
if (!wsdlText) throw new Error("wsdl:import requires either `url` or `xml`");
|
|
321
321
|
return importWsdl(wsdlText, { name: opts.name, existingMockPorts: opts.existingMockPorts });
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const promises = require("fs/promises");
|
|
4
|
+
const jsYaml = require("js-yaml");
|
|
5
|
+
const undici = require("undici");
|
|
6
|
+
const coverage = require("./chunks/coverage-C54mZEh_.js");
|
|
7
|
+
const cliCommon = require("./chunks/cli-common-BKYgsbGB.js");
|
|
8
|
+
require("path");
|
|
9
|
+
require("crypto");
|
|
10
|
+
const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
11
|
+
function resolveRef(spec, ref) {
|
|
12
|
+
const parts = ref.replace(/^#\//, "").split("/");
|
|
13
|
+
return parts.reduce((o, k) => o?.[decodeURIComponent(k.replace(/~1/g, "/").replace(/~0/g, "~"))], spec);
|
|
14
|
+
}
|
|
15
|
+
function deref(spec, node, depth = 0, seen = /* @__PURE__ */ new Set()) {
|
|
16
|
+
if (!node || typeof node !== "object" || depth > 8) return node;
|
|
17
|
+
if (Array.isArray(node)) return node.map((n) => deref(spec, n, depth + 1, seen));
|
|
18
|
+
if ("$ref" in node) {
|
|
19
|
+
const target = resolveRef(spec, node.$ref);
|
|
20
|
+
if (!target || seen.has(target)) return {};
|
|
21
|
+
return deref(spec, target, depth + 1, /* @__PURE__ */ new Set([...seen, target]));
|
|
22
|
+
}
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const [k, v] of Object.entries(node)) out[k] = deref(spec, v, depth + 1, seen);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function walk(schema, prefix, depth, types, required) {
|
|
28
|
+
if (!schema || typeof schema !== "object" || depth > 8) return;
|
|
29
|
+
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
|
|
30
|
+
if (type === "object" || schema.properties) {
|
|
31
|
+
const req = schema.required ?? [];
|
|
32
|
+
for (const [name, sub] of Object.entries(schema.properties ?? {})) {
|
|
33
|
+
const p = prefix ? `${prefix}.${name}` : name;
|
|
34
|
+
const subType = (Array.isArray(sub?.type) ? sub.type[0] : sub?.type) ?? (sub?.properties ? "object" : "any");
|
|
35
|
+
types.set(p, subType);
|
|
36
|
+
if (req.includes(name)) required.add(p);
|
|
37
|
+
walk(sub, p, depth + 1, types, required);
|
|
38
|
+
}
|
|
39
|
+
} else if (type === "array" && schema.items) {
|
|
40
|
+
walk(schema.items, `${prefix}[]`, depth + 1, types, required);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function schemaInfo(spec, schema) {
|
|
44
|
+
const types = /* @__PURE__ */ new Map();
|
|
45
|
+
const required = /* @__PURE__ */ new Set();
|
|
46
|
+
if (schema) walk(deref(spec, schema), "", 0, types, required);
|
|
47
|
+
return { types, required };
|
|
48
|
+
}
|
|
49
|
+
function requestSchema(spec, op) {
|
|
50
|
+
return deref(spec, op?.requestBody)?.content?.["application/json"]?.schema;
|
|
51
|
+
}
|
|
52
|
+
function successResponseSchema(spec, op) {
|
|
53
|
+
const responses = op?.responses ?? {};
|
|
54
|
+
const code = Object.keys(responses).filter((c) => /^2\d\d$/.test(c)).sort()[0];
|
|
55
|
+
return code ? deref(spec, responses[code])?.content?.["application/json"]?.schema : void 0;
|
|
56
|
+
}
|
|
57
|
+
function paramRequired(spec, pathItem, op) {
|
|
58
|
+
const raw = [...pathItem?.parameters ?? [], ...op?.parameters ?? []].map((p) => deref(spec, p));
|
|
59
|
+
const m = /* @__PURE__ */ new Map();
|
|
60
|
+
for (const p of raw) if (p?.name && p?.in) m.set(`${p.in}:${p.name}`, !!p.required);
|
|
61
|
+
return m;
|
|
62
|
+
}
|
|
63
|
+
function operations(spec) {
|
|
64
|
+
const map = /* @__PURE__ */ new Map();
|
|
65
|
+
for (const [path, item] of Object.entries(spec?.paths ?? {})) {
|
|
66
|
+
if (!item || typeof item !== "object") continue;
|
|
67
|
+
for (const [method, op] of Object.entries(item)) {
|
|
68
|
+
if (!HTTP_METHODS.includes(method.toLowerCase())) continue;
|
|
69
|
+
map.set(`${method.toUpperCase()} ${path}`, { method: method.toUpperCase(), path, pathItem: item, op });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return map;
|
|
73
|
+
}
|
|
74
|
+
function diffSpecs(oldSpec, newSpec) {
|
|
75
|
+
const oldOps = operations(oldSpec);
|
|
76
|
+
const newOps = operations(newSpec);
|
|
77
|
+
const changes = [];
|
|
78
|
+
for (const [key, o] of oldOps) {
|
|
79
|
+
if (!newOps.has(key)) {
|
|
80
|
+
changes.push({ kind: "operation-removed", breaking: true, method: o.method, path: o.path, detail: `Operation ${key} was removed` });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const [key, n] of newOps) {
|
|
84
|
+
if (!oldOps.has(key)) {
|
|
85
|
+
changes.push({ kind: "operation-added", breaking: false, method: n.method, path: n.path, detail: `Operation ${key} was added` });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const o = oldOps.get(key);
|
|
89
|
+
const label = `${n.method} ${n.path}`;
|
|
90
|
+
const oReq = schemaInfo(oldSpec, requestSchema(oldSpec, o.op));
|
|
91
|
+
const nReq = schemaInfo(newSpec, requestSchema(newSpec, n.op));
|
|
92
|
+
for (const p of nReq.required) {
|
|
93
|
+
if (!oReq.required.has(p)) {
|
|
94
|
+
changes.push({ kind: "request-required-added", breaking: true, method: n.method, path: n.path, detail: `${label}: request field "${p}" is now required` });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const [p, t] of nReq.types) {
|
|
98
|
+
const ot = oReq.types.get(p);
|
|
99
|
+
if (ot && ot !== t) {
|
|
100
|
+
changes.push({ kind: "request-type-changed", breaking: true, method: n.method, path: n.path, detail: `${label}: request field "${p}" type ${ot} -> ${t}` });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const oRes = schemaInfo(oldSpec, successResponseSchema(oldSpec, o.op));
|
|
104
|
+
const nRes = schemaInfo(newSpec, successResponseSchema(newSpec, n.op));
|
|
105
|
+
for (const [p, t] of oRes.types) {
|
|
106
|
+
if (!nRes.types.has(p)) {
|
|
107
|
+
changes.push({ kind: "response-removed", breaking: true, method: n.method, path: n.path, detail: `${label}: response field "${p}" was removed` });
|
|
108
|
+
} else if (nRes.types.get(p) !== t) {
|
|
109
|
+
changes.push({ kind: "response-type-changed", breaking: true, method: n.method, path: n.path, detail: `${label}: response field "${p}" type ${t} -> ${nRes.types.get(p)}` });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const oParams = paramRequired(oldSpec, o.pathItem, o.op);
|
|
113
|
+
const nParams = paramRequired(newSpec, n.pathItem, n.op);
|
|
114
|
+
for (const [k, req] of nParams) {
|
|
115
|
+
if (req && !oParams.get(k)) {
|
|
116
|
+
changes.push({ kind: "param-required-added", breaking: true, method: n.method, path: n.path, detail: `${label}: parameter "${k}" is now required` });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const oCodes = Object.keys(o.op?.responses ?? {}).filter((c) => /^2\d\d$/.test(c));
|
|
120
|
+
const nCodes = new Set(Object.keys(n.op?.responses ?? {}));
|
|
121
|
+
for (const c of oCodes) {
|
|
122
|
+
if (!nCodes.has(c)) {
|
|
123
|
+
changes.push({ kind: "success-code-removed", breaking: true, method: n.method, path: n.path, detail: `${label}: success response ${c} was removed` });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return changes;
|
|
128
|
+
}
|
|
129
|
+
function summarizeDiff(changes) {
|
|
130
|
+
return {
|
|
131
|
+
breaking: changes.filter((c) => c.breaking).length,
|
|
132
|
+
nonBreaking: changes.filter((c) => !c.breaking).length
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async function loadSpec(source) {
|
|
136
|
+
const isUrl = /^https?:\/\//i.test(source);
|
|
137
|
+
const raw = isUrl ? await (async () => {
|
|
138
|
+
const r = await undici.fetch(source);
|
|
139
|
+
if (!r.ok) throw new Error(`HTTP ${r.status} fetching ${source}`);
|
|
140
|
+
return r.text();
|
|
141
|
+
})() : await promises.readFile(source, "utf8");
|
|
142
|
+
return /\.ya?ml$/i.test(source) || !isUrl && !raw.trim().startsWith("{") ? jsYaml.load(raw) : JSON.parse(raw);
|
|
143
|
+
}
|
|
144
|
+
async function analyseImpact(changes, wsPath) {
|
|
145
|
+
const { workspace, dir } = await cliCommon.loadWorkspace(wsPath);
|
|
146
|
+
const collections = await cliCommon.loadCollections(workspace, dir);
|
|
147
|
+
const reqs = [];
|
|
148
|
+
for (const col of collections) {
|
|
149
|
+
for (const r of Object.values(col.requests ?? {})) {
|
|
150
|
+
if (r.disabled) continue;
|
|
151
|
+
reqs.push({ name: `${col.name} / ${r.name}`, method: r.method, url: r.url });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return changes.filter((c) => c.breaking && c.method).map((change) => ({
|
|
155
|
+
change,
|
|
156
|
+
tests: reqs.filter((r) => r.method.toUpperCase() === change.method && coverage.pathMatches(r.url, change.path)).map((r) => r.name)
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
async function main() {
|
|
160
|
+
const argv = process.argv.slice(2);
|
|
161
|
+
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
162
|
+
const args = cliCommon.parseArgs(argv);
|
|
163
|
+
if (args.help || args.h || positional.length < 2) {
|
|
164
|
+
console.log("\nUsage:\n api-spector compare <old-spec> <new-spec> [--workspace <path>] [--fail-on-breaking] [--json]\n");
|
|
165
|
+
process.exit(positional.length < 2 && !args.help && !args.h ? 2 : 0);
|
|
166
|
+
}
|
|
167
|
+
const [oldSource, newSource] = positional;
|
|
168
|
+
let changes;
|
|
169
|
+
try {
|
|
170
|
+
const [oldSpec, newSpec] = await Promise.all([loadSpec(oldSource), loadSpec(newSource)]);
|
|
171
|
+
changes = diffSpecs(oldSpec, newSpec);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
174
|
+
process.exit(2);
|
|
175
|
+
}
|
|
176
|
+
const impact = args.workspace ? await analyseImpact(changes, args.workspace) : null;
|
|
177
|
+
const { breaking, nonBreaking } = summarizeDiff(changes);
|
|
178
|
+
if (args.json) {
|
|
179
|
+
console.log(JSON.stringify({ changes, impact, summary: { breaking, nonBreaking } }, null, 2));
|
|
180
|
+
} else {
|
|
181
|
+
console.log("");
|
|
182
|
+
if (breaking) {
|
|
183
|
+
console.log(cliCommon.color(" BREAKING CHANGES", cliCommon.C.bold, cliCommon.C.red));
|
|
184
|
+
for (const c of changes.filter((c2) => c2.breaking)) console.log(` ${cliCommon.color("✗", cliCommon.C.red)} ${c.detail}`);
|
|
185
|
+
console.log("");
|
|
186
|
+
}
|
|
187
|
+
if (nonBreaking) {
|
|
188
|
+
console.log(cliCommon.color(" NON-BREAKING", cliCommon.C.bold, cliCommon.C.green));
|
|
189
|
+
for (const c of changes.filter((c2) => !c2.breaking)) console.log(` ${cliCommon.color("✓", cliCommon.C.green)} ${c.detail}`);
|
|
190
|
+
console.log("");
|
|
191
|
+
}
|
|
192
|
+
if (!changes.length) console.log(cliCommon.color(" No differences.\n", cliCommon.C.gray));
|
|
193
|
+
if (impact) {
|
|
194
|
+
const hit = impact.filter((a) => a.tests.length > 0);
|
|
195
|
+
const totalTests = new Set(hit.flatMap((a) => a.tests)).size;
|
|
196
|
+
console.log(cliCommon.color(" IMPACT", cliCommon.C.bold, cliCommon.C.white));
|
|
197
|
+
if (!breaking) {
|
|
198
|
+
console.log(cliCommon.color(" No breaking changes. Safe to deploy.\n", cliCommon.C.green));
|
|
199
|
+
} else if (totalTests === 0) {
|
|
200
|
+
console.log(` ${breaking} breaking change(s), but no test in this workspace exercises the affected operations.`);
|
|
201
|
+
console.log(cliCommon.color(" Recommendation: add tests for those operations, then re-check.\n", cliCommon.C.yellow));
|
|
202
|
+
} else {
|
|
203
|
+
for (const a of hit) {
|
|
204
|
+
console.log(` ${cliCommon.color(a.change.detail, cliCommon.C.yellow)}`);
|
|
205
|
+
for (const t of a.tests) console.log(cliCommon.color(` - ${t}`, cliCommon.C.gray));
|
|
206
|
+
}
|
|
207
|
+
console.log("");
|
|
208
|
+
console.log(cliCommon.color(` BLOCK DEPLOYMENT: ${breaking} breaking change(s) affect ${totalTests} test(s).`, cliCommon.C.bold, cliCommon.C.red));
|
|
209
|
+
console.log("");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (args["fail-on-breaking"] && breaking > 0) process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
main().catch((err) => {
|
|
216
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
217
|
+
process.exit(2);
|
|
218
|
+
});
|
package/out/main/contract.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
const promises = require("fs/promises");
|
|
4
4
|
const path = require("path");
|
|
5
|
-
const snapshots = require("./chunks/snapshots-
|
|
5
|
+
const snapshots = require("./chunks/snapshots-BJiZNuRN.js");
|
|
6
6
|
const crypto = require("crypto");
|
|
7
7
|
const undici = require("undici");
|
|
8
8
|
const os = require("os");
|
|
@@ -10,9 +10,9 @@ const cliCommon = require("./chunks/cli-common-BKYgsbGB.js");
|
|
|
10
10
|
const child_process = require("child_process");
|
|
11
11
|
const jsYaml = require("js-yaml");
|
|
12
12
|
const environments = require("./chunks/environments-iM3SUM-4.js");
|
|
13
|
-
require("./chunks/request-exec-
|
|
13
|
+
require("./chunks/request-exec-D_xyyt3_.js");
|
|
14
14
|
require("tls");
|
|
15
|
-
require("./chunks/handle-
|
|
15
|
+
require("./chunks/handle-b6bp3fb0.js");
|
|
16
16
|
require("node:fs");
|
|
17
17
|
require("node:os");
|
|
18
18
|
require("node:path");
|