@testsmith/api-spector 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/main/agents.js +24 -35
- package/out/main/chunks/{auth-builder-CRQayp8x.js → auth-builder-CUs9yzOF.js} +4 -3
- package/out/main/chunks/cli-common-CDQY1erJ.js +98 -0
- package/out/main/chunks/handle-C0IQL-Vl.js +164 -0
- package/out/main/chunks/{import-C9qdkBCH.js → import-C5Ngq8hk.js} +2 -1
- package/out/main/chunks/{ipc-validate-CscN4HfG.js → ipc-validate-k6KI8adf.js} +4 -2
- package/out/main/chunks/{request-collection-d2vgYls1.js → request-collection-8TOVNXE0.js} +296 -350
- package/out/main/chunks/{snapshots-CQliv7WB.js → snapshots-UFd3XgSS.js} +366 -26
- package/out/main/chunks/{soap-handler-Cpj-JwyA.js → soap-handler-B9x_YCtj.js} +6 -5
- package/out/main/contract.js +515 -58
- package/out/main/index.js +825 -785
- package/out/main/mock.js +30 -84
- package/out/main/record.js +19 -53
- package/out/main/runner.js +59 -321
- package/out/main/wsdl.js +9 -45
- package/out/preload/index.js +243 -90
- package/out/renderer/assets/{index-DYoP9JFL.js → index-BSWo6Dkt.js} +2801 -2950
- package/out/renderer/assets/index-CjvjKHtF.css +2 -0
- package/out/renderer/index.html +2 -2
- package/package.json +4 -3
- package/out/renderer/assets/index-D4vegaiz.css +0 -2
package/out/main/contract.js
CHANGED
|
@@ -2,67 +2,394 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
const promises = require("fs/promises");
|
|
4
4
|
const path = require("path");
|
|
5
|
-
const snapshots = require("./chunks/snapshots-
|
|
6
|
-
const os = require("os");
|
|
5
|
+
const snapshots = require("./chunks/snapshots-UFd3XgSS.js");
|
|
7
6
|
const crypto = require("crypto");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const cliCommon = require("./chunks/cli-common-CDQY1erJ.js");
|
|
8
9
|
require("undici");
|
|
9
|
-
require("js
|
|
10
|
-
require("ajv");
|
|
11
|
-
require("./chunks/auth-builder-CRQayp8x.js");
|
|
10
|
+
require("./chunks/auth-builder-CUs9yzOF.js");
|
|
12
11
|
require("http");
|
|
12
|
+
require("./chunks/handle-C0IQL-Vl.js");
|
|
13
13
|
require("dayjs");
|
|
14
14
|
require("vm");
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
i++;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
15
|
+
require("js-yaml");
|
|
16
|
+
require("ajv");
|
|
17
|
+
function escapeXml(s) {
|
|
18
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
19
|
+
}
|
|
20
|
+
function caseXml(r) {
|
|
21
|
+
const name = escapeXml(`${r.method} ${r.requestName}`);
|
|
22
|
+
const time = ((r.durationMs ?? 0) / 1e3).toFixed(3);
|
|
23
|
+
const classnm = escapeXml(r.url);
|
|
24
|
+
if (r.passed) {
|
|
25
|
+
return ` <testcase name="${name}" classname="${classnm}" time="${time}"/>`;
|
|
29
26
|
}
|
|
30
|
-
|
|
27
|
+
const messages = r.violations.map((v) => `${v.type}: ${v.message}${v.path ? ` (${v.path})` : ""}`);
|
|
28
|
+
const summary = escapeXml(messages[0] ?? "contract failed");
|
|
29
|
+
const detail = escapeXml(messages.join("\n"));
|
|
30
|
+
return [
|
|
31
|
+
` <testcase name="${name}" classname="${classnm}" time="${time}">`,
|
|
32
|
+
` <failure message="${summary}">${detail}</failure>`,
|
|
33
|
+
` </testcase>`
|
|
34
|
+
].join("\n");
|
|
31
35
|
}
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
function toJUnitXml(report, suiteName = "contract") {
|
|
37
|
+
const time = (report.durationMs / 1e3).toFixed(3);
|
|
38
|
+
const lines = [
|
|
39
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
40
|
+
`<testsuites name="api-spector" tests="${report.total}" failures="${report.failed}" time="${time}">`,
|
|
41
|
+
` <testsuite name="${escapeXml(`${suiteName}:${report.mode}`)}" tests="${report.total}" failures="${report.failed}" time="${time}">`,
|
|
42
|
+
...report.results.map(caseXml),
|
|
43
|
+
" </testsuite>",
|
|
44
|
+
"</testsuites>"
|
|
45
|
+
];
|
|
46
|
+
return lines.join("\n") + "\n";
|
|
39
47
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return { workspace: JSON.parse(raw), dir: path.dirname(path.resolve(resolved)) };
|
|
48
|
+
const RESULTS_DIR = "contracts/results";
|
|
49
|
+
function safe(part) {
|
|
50
|
+
return part.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
|
|
44
51
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
function resultPath(dir, pacticipant, version) {
|
|
53
|
+
return path.join(dir, RESULTS_DIR, safe(pacticipant), `${safe(version)}.json`);
|
|
54
|
+
}
|
|
55
|
+
async function recordResult(dir, pacticipant, version, report, now) {
|
|
56
|
+
const file = resultPath(dir, pacticipant, version);
|
|
57
|
+
await promises.mkdir(path.join(dir, RESULTS_DIR, safe(pacticipant)), { recursive: true });
|
|
58
|
+
const record = {
|
|
59
|
+
pacticipant,
|
|
60
|
+
version,
|
|
61
|
+
recordedAt: now,
|
|
62
|
+
passed: report.failed === 0,
|
|
63
|
+
report
|
|
64
|
+
};
|
|
65
|
+
await promises.writeFile(file, JSON.stringify(record, null, 2), "utf8");
|
|
66
|
+
return file;
|
|
67
|
+
}
|
|
68
|
+
async function listResults(dir) {
|
|
69
|
+
const root = path.join(dir, RESULTS_DIR);
|
|
70
|
+
const out = [];
|
|
71
|
+
let pacticipants;
|
|
72
|
+
try {
|
|
73
|
+
pacticipants = await promises.readdir(root);
|
|
74
|
+
} catch {
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
for (const p of pacticipants) {
|
|
78
|
+
let files;
|
|
48
79
|
try {
|
|
49
|
-
|
|
50
|
-
const col = JSON.parse(raw);
|
|
51
|
-
if (!filterName || col.name === filterName) cols.push(col);
|
|
80
|
+
files = await promises.readdir(path.join(root, p));
|
|
52
81
|
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
for (const f of files) {
|
|
85
|
+
if (!f.endsWith(".json")) continue;
|
|
86
|
+
try {
|
|
87
|
+
out.push(JSON.parse(await promises.readFile(path.join(root, p, f), "utf8")));
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
53
90
|
}
|
|
54
91
|
}
|
|
55
|
-
return
|
|
92
|
+
return out;
|
|
56
93
|
}
|
|
57
|
-
async function
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
94
|
+
async function canIDeploy(dir, pacticipant, version) {
|
|
95
|
+
const file = resultPath(dir, pacticipant, version);
|
|
96
|
+
let record;
|
|
97
|
+
try {
|
|
98
|
+
record = JSON.parse(await promises.readFile(file, "utf8"));
|
|
99
|
+
} catch {
|
|
100
|
+
return {
|
|
101
|
+
deployable: false,
|
|
102
|
+
reason: `No verification result recorded for ${pacticipant}@${version}. Run \`contract run … --record --pacticipant ${pacticipant} --app-version ${version}\` first.`
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (record.passed) {
|
|
106
|
+
return {
|
|
107
|
+
deployable: true,
|
|
108
|
+
reason: `${pacticipant}@${version} passed all ${record.report.total} contract checks (verified ${record.recordedAt}).`,
|
|
109
|
+
record
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
deployable: false,
|
|
114
|
+
reason: `${pacticipant}@${version} has ${record.report.failed}/${record.report.total} failing contract checks (verified ${record.recordedAt}).`,
|
|
115
|
+
record
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function parsePath(path2) {
|
|
119
|
+
const tokens = [];
|
|
120
|
+
const re = /\.([^.[\]]+)|\['([^']*)'\]|\[(\d+|\*)\]/g;
|
|
121
|
+
let m;
|
|
122
|
+
while ((m = re.exec(path2)) !== null) {
|
|
123
|
+
if (m[1] !== void 0) tokens.push({ key: m[1] });
|
|
124
|
+
else if (m[2] !== void 0) tokens.push({ key: m[2] });
|
|
125
|
+
else if (m[3] !== void 0) tokens.push({ index: m[3] === "*" ? "*" : Number(m[3]) });
|
|
126
|
+
}
|
|
127
|
+
return tokens;
|
|
128
|
+
}
|
|
129
|
+
function ruleToMatcher(value, rule) {
|
|
130
|
+
switch (rule.match) {
|
|
131
|
+
case "type":
|
|
132
|
+
if (Array.isArray(value)) {
|
|
133
|
+
return { [snapshots.MATCH_KEY]: "eachLike", value: value[0] ?? {}, min: rule.min ?? 1 };
|
|
134
|
+
}
|
|
135
|
+
return { [snapshots.MATCH_KEY]: "type", value };
|
|
136
|
+
case "regex":
|
|
137
|
+
return { [snapshots.MATCH_KEY]: "regex", regex: rule.regex ?? ".*", value };
|
|
138
|
+
case "integer":
|
|
139
|
+
return { [snapshots.MATCH_KEY]: "integer", value };
|
|
140
|
+
case "number":
|
|
141
|
+
case "decimal":
|
|
142
|
+
return { [snapshots.MATCH_KEY]: "decimal", value };
|
|
143
|
+
case "boolean":
|
|
144
|
+
return { [snapshots.MATCH_KEY]: "boolean", value };
|
|
145
|
+
case "datetime":
|
|
146
|
+
case "timestamp":
|
|
147
|
+
return { [snapshots.MATCH_KEY]: "datetime", value, format: rule.format };
|
|
148
|
+
case "date":
|
|
149
|
+
return { [snapshots.MATCH_KEY]: "date", value };
|
|
150
|
+
case "time":
|
|
151
|
+
return { [snapshots.MATCH_KEY]: "time", value };
|
|
152
|
+
default:
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function applyAtPath(root, tokens, transform) {
|
|
157
|
+
if (tokens.length === 0) return transform(root);
|
|
158
|
+
const [head, ...rest] = tokens;
|
|
159
|
+
if ("index" in head) {
|
|
160
|
+
if (!Array.isArray(root)) return root;
|
|
161
|
+
if (head.index === "*") {
|
|
162
|
+
return root.map((el) => applyAtPath(el, rest, transform));
|
|
63
163
|
}
|
|
164
|
+
const arr = [...root];
|
|
165
|
+
if (head.index < arr.length) arr[head.index] = applyAtPath(arr[head.index], rest, transform);
|
|
166
|
+
return arr;
|
|
64
167
|
}
|
|
65
|
-
return
|
|
168
|
+
if (typeof root !== "object" || root === null || Array.isArray(root)) return root;
|
|
169
|
+
const obj = { ...root };
|
|
170
|
+
if (head.key in obj) obj[head.key] = applyAtPath(obj[head.key], rest, transform);
|
|
171
|
+
return obj;
|
|
172
|
+
}
|
|
173
|
+
function bodyWithMatchers(body, matchingRules) {
|
|
174
|
+
const bodyRules = matchingRules?.["body"] ?? matchingRules?.["content"];
|
|
175
|
+
if (!bodyRules || body === void 0) return body;
|
|
176
|
+
const entries = Object.entries(bodyRules).sort((a, b) => parsePath(b[0]).length - parsePath(a[0]).length);
|
|
177
|
+
let result = body;
|
|
178
|
+
for (const [path2, def] of entries) {
|
|
179
|
+
const rule = def?.matchers?.[0];
|
|
180
|
+
if (!rule) continue;
|
|
181
|
+
const tokens = parsePath(path2);
|
|
182
|
+
result = applyAtPath(result, tokens, (v) => ruleToMatcher(v, rule));
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
function providerStatesOf(interaction) {
|
|
187
|
+
const v3 = interaction["providerStates"];
|
|
188
|
+
if (Array.isArray(v3)) return v3.map((s) => s?.name ?? "").filter(Boolean);
|
|
189
|
+
const v2 = interaction["providerState"] ?? interaction["provider_state"];
|
|
190
|
+
return typeof v2 === "string" && v2 ? [v2] : [];
|
|
191
|
+
}
|
|
192
|
+
function queryToParams(query) {
|
|
193
|
+
const params = [];
|
|
194
|
+
if (!query) return params;
|
|
195
|
+
if (typeof query === "string") {
|
|
196
|
+
for (const pair of query.split("&")) {
|
|
197
|
+
const [k, v = ""] = pair.split("=");
|
|
198
|
+
if (k) params.push({ key: decodeURIComponent(k), value: decodeURIComponent(v), enabled: true });
|
|
199
|
+
}
|
|
200
|
+
} else if (typeof query === "object") {
|
|
201
|
+
for (const [k, vals] of Object.entries(query)) {
|
|
202
|
+
const list = Array.isArray(vals) ? vals : [vals];
|
|
203
|
+
for (const v of list) params.push({ key: k, value: String(v ?? ""), enabled: true });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return params;
|
|
207
|
+
}
|
|
208
|
+
function headerEntries(headers) {
|
|
209
|
+
if (!headers || typeof headers !== "object") return [];
|
|
210
|
+
return Object.entries(headers).map(([key, value]) => ({
|
|
211
|
+
key,
|
|
212
|
+
value: Array.isArray(value) ? value.join(", ") : String(value ?? "")
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
function headersToKv(headers) {
|
|
216
|
+
return headerEntries(headers).map((h) => ({ ...h, enabled: true }));
|
|
217
|
+
}
|
|
218
|
+
function headersToContract(headers) {
|
|
219
|
+
return headerEntries(headers).map((h) => ({ ...h, required: true }));
|
|
220
|
+
}
|
|
221
|
+
function importInteraction(interaction) {
|
|
222
|
+
const request = interaction["request"] ?? {};
|
|
223
|
+
const response = interaction["response"] ?? {};
|
|
224
|
+
const method = String(request["method"] ?? "GET").toUpperCase();
|
|
225
|
+
const path2 = String(request["path"] ?? "/");
|
|
226
|
+
const reqBody = request["body"];
|
|
227
|
+
const body = reqBody === void 0 ? { mode: "none" } : typeof reqBody === "string" ? { mode: "raw", raw: reqBody } : { mode: "json", json: JSON.stringify(reqBody, null, 2) };
|
|
228
|
+
const contract = {};
|
|
229
|
+
if (typeof response["status"] === "number") contract.statusCode = response["status"];
|
|
230
|
+
const respHeaders = headersToContract(response["headers"]);
|
|
231
|
+
if (respHeaders?.length) contract.headers = respHeaders;
|
|
232
|
+
if (response["body"] !== void 0) {
|
|
233
|
+
const example = bodyWithMatchers(response["body"], response["matchingRules"]);
|
|
234
|
+
contract.bodyMatcher = JSON.stringify(example, null, 2);
|
|
235
|
+
}
|
|
236
|
+
const states = providerStatesOf(interaction);
|
|
237
|
+
if (states.length) contract.providerStates = states;
|
|
238
|
+
return {
|
|
239
|
+
id: crypto.randomUUID(),
|
|
240
|
+
name: String(interaction["description"] ?? `${method} ${path2}`),
|
|
241
|
+
method,
|
|
242
|
+
url: `{{baseUrl}}${path2}`,
|
|
243
|
+
headers: headersToKv(request["headers"]),
|
|
244
|
+
params: queryToParams(request["query"]),
|
|
245
|
+
auth: { type: "none" },
|
|
246
|
+
body,
|
|
247
|
+
contract,
|
|
248
|
+
meta: { tags: ["pact"] }
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function importPact(json) {
|
|
252
|
+
const pact = typeof json === "string" ? JSON.parse(json) : json;
|
|
253
|
+
const consumer = pact["consumer"]?.name ?? "consumer";
|
|
254
|
+
const provider = pact["provider"]?.name ?? "provider";
|
|
255
|
+
const metadata = pact["metadata"];
|
|
256
|
+
const specVersion = String(
|
|
257
|
+
metadata?.["pactSpecification"]?.version ?? metadata?.["pact-specification"]?.version ?? "3.0.0"
|
|
258
|
+
);
|
|
259
|
+
const interactions = pact["interactions"] ?? [];
|
|
260
|
+
const httpInteractions = interactions.filter((i) => {
|
|
261
|
+
const type = i["type"];
|
|
262
|
+
return type === void 0 || type === "Synchronous/HTTP" || type === "HTTP";
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
consumer,
|
|
266
|
+
provider,
|
|
267
|
+
specVersion,
|
|
268
|
+
requests: httpInteractions.map(importInteraction)
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function pactToCollection(result) {
|
|
272
|
+
const requests = {};
|
|
273
|
+
for (const r of result.requests) requests[r.id] = r;
|
|
274
|
+
return {
|
|
275
|
+
version: "1.0",
|
|
276
|
+
id: crypto.randomUUID(),
|
|
277
|
+
name: `${result.consumer} → ${result.provider}`,
|
|
278
|
+
description: `Imported from Pact (spec ${result.specVersion})`,
|
|
279
|
+
rootFolder: {
|
|
280
|
+
id: crypto.randomUUID(),
|
|
281
|
+
name: "root",
|
|
282
|
+
folders: [],
|
|
283
|
+
requestIds: result.requests.map((r) => r.id)
|
|
284
|
+
},
|
|
285
|
+
requests,
|
|
286
|
+
collectionVariables: { baseUrl: "" }
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function isMatcher(node) {
|
|
290
|
+
return typeof node === "object" && node !== null && !Array.isArray(node) && typeof node[snapshots.MATCH_KEY] === "string";
|
|
291
|
+
}
|
|
292
|
+
function exampleToPactBody(node) {
|
|
293
|
+
const rules = {};
|
|
294
|
+
function walk(n, path2) {
|
|
295
|
+
if (isMatcher(n)) {
|
|
296
|
+
const kind = n[snapshots.MATCH_KEY];
|
|
297
|
+
switch (kind) {
|
|
298
|
+
case "type":
|
|
299
|
+
rules[path2] = { matchers: [{ match: "type" }] };
|
|
300
|
+
return walk(n.value, path2);
|
|
301
|
+
case "eachLike": {
|
|
302
|
+
rules[path2] = { matchers: [{ match: "type", min: n.min ?? 1 }] };
|
|
303
|
+
return [walk(n.value, `${path2}[*]`)];
|
|
304
|
+
}
|
|
305
|
+
case "regex":
|
|
306
|
+
rules[path2] = { matchers: [{ match: "regex", regex: n.regex ?? ".*" }] };
|
|
307
|
+
return n.value ?? "";
|
|
308
|
+
case "integer":
|
|
309
|
+
rules[path2] = { matchers: [{ match: "integer" }] };
|
|
310
|
+
return n.value ?? 0;
|
|
311
|
+
case "decimal":
|
|
312
|
+
case "number":
|
|
313
|
+
rules[path2] = { matchers: [{ match: "decimal" }] };
|
|
314
|
+
return n.value ?? 0;
|
|
315
|
+
case "boolean":
|
|
316
|
+
rules[path2] = { matchers: [{ match: "type" }] };
|
|
317
|
+
return n.value ?? false;
|
|
318
|
+
case "datetime":
|
|
319
|
+
case "timestamp":
|
|
320
|
+
rules[path2] = { matchers: [{ match: "datetime", format: n.format }] };
|
|
321
|
+
return n.value ?? "";
|
|
322
|
+
case "date":
|
|
323
|
+
rules[path2] = { matchers: [{ match: "date" }] };
|
|
324
|
+
return n.value ?? "";
|
|
325
|
+
case "time":
|
|
326
|
+
rules[path2] = { matchers: [{ match: "time" }] };
|
|
327
|
+
return n.value ?? "";
|
|
328
|
+
default:
|
|
329
|
+
return n.value;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (Array.isArray(n)) return n.map((el, i) => walk(el, `${path2}[${i}]`));
|
|
333
|
+
if (typeof n === "object" && n !== null) {
|
|
334
|
+
const out = {};
|
|
335
|
+
for (const [k, v] of Object.entries(n)) out[k] = walk(v, `${path2}.${k}`);
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
return n;
|
|
339
|
+
}
|
|
340
|
+
const body = walk(node, "$");
|
|
341
|
+
return { body, rules };
|
|
342
|
+
}
|
|
343
|
+
function kvToHeaders(kv) {
|
|
344
|
+
if (!kv?.length) return void 0;
|
|
345
|
+
const out = {};
|
|
346
|
+
for (const h of kv) if (h.enabled !== false && h.key) out[h.key] = h.value;
|
|
347
|
+
return Object.keys(out).length ? out : void 0;
|
|
348
|
+
}
|
|
349
|
+
function exportPact(consumer, provider, requests) {
|
|
350
|
+
const interactions = requests.filter((r) => r.contract).map((r) => {
|
|
351
|
+
const c = r.contract;
|
|
352
|
+
const path2 = r.url.replace(/^\{\{baseUrl\}\}/, "").replace(/^https?:\/\/[^/]+/, "") || "/";
|
|
353
|
+
const query = {};
|
|
354
|
+
for (const p of r.params ?? []) {
|
|
355
|
+
if (p.enabled === false || !p.key) continue;
|
|
356
|
+
(query[p.key] ??= []).push(p.value);
|
|
357
|
+
}
|
|
358
|
+
const response = {};
|
|
359
|
+
if (c.statusCode !== void 0) response["status"] = c.statusCode;
|
|
360
|
+
const respHeaders = kvToHeaders(c.headers);
|
|
361
|
+
if (respHeaders) response["headers"] = respHeaders;
|
|
362
|
+
if (c.bodyMatcher?.trim()) {
|
|
363
|
+
try {
|
|
364
|
+
const { body, rules } = exampleToPactBody(JSON.parse(c.bodyMatcher));
|
|
365
|
+
response["body"] = body;
|
|
366
|
+
if (Object.keys(rules).length) response["matchingRules"] = { body: rules };
|
|
367
|
+
} catch {
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const request = { method: r.method, path: path2 };
|
|
371
|
+
if (Object.keys(query).length) request["query"] = query;
|
|
372
|
+
const reqHeaders = kvToHeaders(r.headers);
|
|
373
|
+
if (reqHeaders) request["headers"] = reqHeaders;
|
|
374
|
+
if (r.body?.mode === "json" && r.body.json) {
|
|
375
|
+
try {
|
|
376
|
+
request["body"] = JSON.parse(r.body.json);
|
|
377
|
+
} catch {
|
|
378
|
+
}
|
|
379
|
+
} else if (r.body?.mode === "raw" && r.body.raw) request["body"] = r.body.raw;
|
|
380
|
+
const interaction = { description: r.name, request, response };
|
|
381
|
+
if (c.providerStates?.length) interaction["providerStates"] = c.providerStates.map((name) => ({ name }));
|
|
382
|
+
return interaction;
|
|
383
|
+
});
|
|
384
|
+
return {
|
|
385
|
+
consumer: { name: consumer },
|
|
386
|
+
provider: { name: provider },
|
|
387
|
+
interactions,
|
|
388
|
+
metadata: {
|
|
389
|
+
pactSpecification: { version: "3.0.0" },
|
|
390
|
+
client: { name: "api-spector" }
|
|
391
|
+
}
|
|
392
|
+
};
|
|
66
393
|
}
|
|
67
394
|
async function cmdList(args) {
|
|
68
395
|
const wsArg = args["workspace"];
|
|
@@ -70,7 +397,7 @@ async function cmdList(args) {
|
|
|
70
397
|
console.error(" [error] --workspace <path> is required");
|
|
71
398
|
process.exit(2);
|
|
72
399
|
}
|
|
73
|
-
const { workspace, dir } = await loadWorkspace(wsArg);
|
|
400
|
+
const { workspace, dir } = await cliCommon.loadWorkspace(wsArg);
|
|
74
401
|
const snapshots$1 = await snapshots.listSnapshots(dir, workspace.contracts ?? []);
|
|
75
402
|
if (snapshots$1.length === 0) {
|
|
76
403
|
console.log(" No contract snapshots. Capture one from the app or via:");
|
|
@@ -110,14 +437,14 @@ async function cmdRun(args) {
|
|
|
110
437
|
console.error(" [error] --workspace <path> is required");
|
|
111
438
|
process.exit(2);
|
|
112
439
|
}
|
|
113
|
-
if (mode !== "consumer" && mode !== "provider" && mode !== "bidirectional") {
|
|
114
|
-
console.error(" [error] --mode must be one of: consumer, provider, bidirectional");
|
|
440
|
+
if (mode !== "consumer" && mode !== "provider" && mode !== "provider-live" && mode !== "bidirectional") {
|
|
441
|
+
console.error(" [error] --mode must be one of: consumer, provider, provider-live, bidirectional");
|
|
115
442
|
process.exit(2);
|
|
116
443
|
}
|
|
117
|
-
const { workspace, dir } = await loadWorkspace(wsArg);
|
|
444
|
+
const { workspace, dir } = await cliCommon.loadWorkspace(wsArg);
|
|
118
445
|
const collectionName = typeof args["collection"] === "string" ? args["collection"] : void 0;
|
|
119
|
-
const collections = await loadCollections(workspace, dir, collectionName);
|
|
120
|
-
const envs = await loadEnvironments(workspace, dir);
|
|
446
|
+
const collections = await cliCommon.loadCollections(workspace, dir, { filterName: collectionName });
|
|
447
|
+
const envs = await cliCommon.loadEnvironments(workspace, dir);
|
|
121
448
|
const envName = typeof args["environment"] === "string" ? args["environment"] : void 0;
|
|
122
449
|
const activeEnv = envName ? envs.find((e) => e.name === envName) : envs[0];
|
|
123
450
|
const envVars = {};
|
|
@@ -125,9 +452,7 @@ async function cmdRun(args) {
|
|
|
125
452
|
const collectionVars = {};
|
|
126
453
|
for (const c of collections) Object.assign(collectionVars, c.collectionVariables ?? {});
|
|
127
454
|
const allRequests = collections.flatMap((c) => Object.values(c.requests));
|
|
128
|
-
const contractRequests = allRequests.filter(
|
|
129
|
-
(r) => r.contract && (r.contract.statusCode !== void 0 || r.contract.bodySchema || r.contract.headers?.length)
|
|
130
|
-
);
|
|
455
|
+
const contractRequests = allRequests.filter((r) => snapshots.hasContract(r.contract));
|
|
131
456
|
let specUrl = typeof args["spec-url"] === "string" ? args["spec-url"] : void 0;
|
|
132
457
|
let specPath = typeof args["spec-path"] === "string" ? args["spec-path"] : void 0;
|
|
133
458
|
let snapshotLabel;
|
|
@@ -140,14 +465,21 @@ async function cmdRun(args) {
|
|
|
140
465
|
snapshotLabel = `${snapshot.name}${snapshot.specVersion ? ` (${snapshot.specVersion})` : ""}`;
|
|
141
466
|
}
|
|
142
467
|
const requestBaseUrl = typeof args["request-base-url"] === "string" ? args["request-base-url"] : void 0;
|
|
143
|
-
|
|
468
|
+
const providerBaseUrl = typeof args["provider-base-url"] === "string" ? args["provider-base-url"] : void 0;
|
|
469
|
+
const stateHandlerUrl = typeof args["states-url"] === "string" ? args["states-url"] : void 0;
|
|
470
|
+
if ((mode === "provider" || mode === "bidirectional") && !specUrl && !specPath) {
|
|
144
471
|
console.error(" [error] Provider / bidirectional mode requires --snapshot, --spec-url, or --spec-path");
|
|
145
472
|
process.exit(2);
|
|
146
473
|
}
|
|
474
|
+
if (mode === "provider-live" && !providerBaseUrl) {
|
|
475
|
+
console.error(" [error] provider-live mode requires --provider-base-url <url>");
|
|
476
|
+
process.exit(2);
|
|
477
|
+
}
|
|
147
478
|
console.log(` Running ${mode} contracts…`);
|
|
148
479
|
if (snapshotLabel) console.log(` Spec: snapshot "${snapshotLabel}"`);
|
|
149
480
|
else if (specUrl) console.log(` Spec: ${specUrl} (live)`);
|
|
150
481
|
else if (specPath) console.log(` Spec: ${specPath}`);
|
|
482
|
+
if (providerBaseUrl) console.log(` Provider: ${providerBaseUrl}`);
|
|
151
483
|
let report;
|
|
152
484
|
const modeValue = mode;
|
|
153
485
|
switch (modeValue) {
|
|
@@ -157,6 +489,9 @@ async function cmdRun(args) {
|
|
|
157
489
|
case "provider":
|
|
158
490
|
report = await snapshots.runProviderVerification(allRequests, envVars, specUrl, specPath, requestBaseUrl);
|
|
159
491
|
break;
|
|
492
|
+
case "provider-live":
|
|
493
|
+
report = await snapshots.runLiveProviderVerification(contractRequests, envVars, collectionVars, providerBaseUrl, stateHandlerUrl);
|
|
494
|
+
break;
|
|
160
495
|
case "bidirectional":
|
|
161
496
|
report = await snapshots.runBidirectional(contractRequests, envVars, collectionVars, specUrl, specPath, requestBaseUrl);
|
|
162
497
|
break;
|
|
@@ -179,17 +514,132 @@ async function cmdRun(args) {
|
|
|
179
514
|
console.log(`
|
|
180
515
|
Report written to ${args["output"]}`);
|
|
181
516
|
}
|
|
517
|
+
if (typeof args["junit"] === "string") {
|
|
518
|
+
await promises.writeFile(args["junit"], toJUnitXml(report), "utf8");
|
|
519
|
+
console.log(` JUnit report written to ${args["junit"]}`);
|
|
520
|
+
}
|
|
521
|
+
if (typeof args["html"] === "string") {
|
|
522
|
+
const html = snapshots.reportToHtml(report, {
|
|
523
|
+
consumer: collections[0]?.name,
|
|
524
|
+
provider: providerBaseUrl,
|
|
525
|
+
spec: snapshotLabel ?? specUrl ?? specPath,
|
|
526
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
527
|
+
});
|
|
528
|
+
await promises.writeFile(args["html"], html, "utf8");
|
|
529
|
+
console.log(` HTML report written to ${args["html"]}`);
|
|
530
|
+
}
|
|
531
|
+
if (args["record"]) {
|
|
532
|
+
const appVersion = typeof args["app-version"] === "string" ? args["app-version"] : void 0;
|
|
533
|
+
if (!appVersion) {
|
|
534
|
+
console.error(" [error] --record requires --app-version <version>");
|
|
535
|
+
process.exit(2);
|
|
536
|
+
}
|
|
537
|
+
const pacticipant = typeof args["pacticipant"] === "string" ? args["pacticipant"] : collections[0]?.name ?? "app";
|
|
538
|
+
const file = await recordResult(dir, pacticipant, appVersion, report, (/* @__PURE__ */ new Date()).toISOString());
|
|
539
|
+
console.log(` Recorded result for ${pacticipant}@${appVersion} → ${file}`);
|
|
540
|
+
}
|
|
182
541
|
process.exit(report.failed === 0 ? 0 : 1);
|
|
183
542
|
}
|
|
543
|
+
async function cmdCanIDeploy(args) {
|
|
544
|
+
const wsArg = args["workspace"];
|
|
545
|
+
const pacticipant = args["pacticipant"];
|
|
546
|
+
const appVersion = args["app-version"];
|
|
547
|
+
if (typeof wsArg !== "string") {
|
|
548
|
+
console.error(" [error] --workspace <path> is required");
|
|
549
|
+
process.exit(2);
|
|
550
|
+
}
|
|
551
|
+
if (typeof pacticipant !== "string") {
|
|
552
|
+
console.error(" [error] --pacticipant <name> is required");
|
|
553
|
+
process.exit(2);
|
|
554
|
+
}
|
|
555
|
+
if (typeof appVersion !== "string") {
|
|
556
|
+
console.error(" [error] --app-version <version> is required");
|
|
557
|
+
process.exit(2);
|
|
558
|
+
}
|
|
559
|
+
const { dir } = await cliCommon.loadWorkspace(wsArg);
|
|
560
|
+
const verdict = await canIDeploy(dir, pacticipant, appVersion);
|
|
561
|
+
console.log("");
|
|
562
|
+
console.log(verdict.deployable ? ` ✓ Computer says yes — safe to deploy.` : ` ✗ Computer says no.`);
|
|
563
|
+
console.log(` ${verdict.reason}`);
|
|
564
|
+
process.exit(verdict.deployable ? 0 : 1);
|
|
565
|
+
}
|
|
566
|
+
async function cmdReport(args) {
|
|
567
|
+
const wsArg = args["workspace"];
|
|
568
|
+
if (typeof wsArg !== "string") {
|
|
569
|
+
console.error(" [error] --workspace <path> is required");
|
|
570
|
+
process.exit(2);
|
|
571
|
+
}
|
|
572
|
+
const out = typeof args["html"] === "string" ? args["html"] : "contract-dashboard.html";
|
|
573
|
+
const { dir } = await cliCommon.loadWorkspace(wsArg);
|
|
574
|
+
const records = await listResults(dir);
|
|
575
|
+
await promises.writeFile(out, snapshots.dashboardToHtml(records, (/* @__PURE__ */ new Date()).toISOString()), "utf8");
|
|
576
|
+
console.log(` Dashboard with ${records.length} recorded result(s) written to ${out}`);
|
|
577
|
+
}
|
|
578
|
+
async function cmdPactImport(args) {
|
|
579
|
+
const file = args["file"];
|
|
580
|
+
if (typeof file !== "string") {
|
|
581
|
+
console.error(" [error] --file <pact.json> is required");
|
|
582
|
+
process.exit(2);
|
|
583
|
+
}
|
|
584
|
+
const result = importPact(await promises.readFile(file, "utf8"));
|
|
585
|
+
console.log(` Imported ${result.requests.length} interaction(s): ${result.consumer} → ${result.provider} (spec ${result.specVersion})`);
|
|
586
|
+
if (typeof args["out"] === "string") {
|
|
587
|
+
const collection = pactToCollection(result);
|
|
588
|
+
await promises.writeFile(args["out"], JSON.stringify(collection, null, 2), "utf8");
|
|
589
|
+
console.log(` Collection written to ${args["out"]}`);
|
|
590
|
+
console.log(` Set the "baseUrl" collection variable, then verify with:`);
|
|
591
|
+
console.log(` api-spector contract run --workspace <path> --mode provider-live --provider-base-url <url>`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
async function cmdPactExport(args) {
|
|
595
|
+
const wsArg = args["workspace"];
|
|
596
|
+
const out = args["out"];
|
|
597
|
+
if (typeof wsArg !== "string") {
|
|
598
|
+
console.error(" [error] --workspace <path> is required");
|
|
599
|
+
process.exit(2);
|
|
600
|
+
}
|
|
601
|
+
if (typeof out !== "string") {
|
|
602
|
+
console.error(" [error] --out <pact.json> is required");
|
|
603
|
+
process.exit(2);
|
|
604
|
+
}
|
|
605
|
+
const { workspace, dir } = await cliCommon.loadWorkspace(wsArg);
|
|
606
|
+
const collectionName = typeof args["collection"] === "string" ? args["collection"] : void 0;
|
|
607
|
+
const collections = await cliCommon.loadCollections(workspace, dir, { filterName: collectionName });
|
|
608
|
+
const requests = collections.flatMap((c) => Object.values(c.requests)).filter((r) => snapshots.hasContract(r.contract));
|
|
609
|
+
if (requests.length === 0) {
|
|
610
|
+
console.error(" [error] No requests with contracts found to export.");
|
|
611
|
+
process.exit(2);
|
|
612
|
+
}
|
|
613
|
+
const consumer = typeof args["consumer"] === "string" ? args["consumer"] : collections[0]?.name ?? "consumer";
|
|
614
|
+
const provider = typeof args["provider"] === "string" ? args["provider"] : "provider";
|
|
615
|
+
const pact = exportPact(consumer, provider, requests);
|
|
616
|
+
await promises.writeFile(out, JSON.stringify(pact, null, 2), "utf8");
|
|
617
|
+
console.log(` Exported ${requests.length} interaction(s) to ${out} (${consumer} → ${provider})`);
|
|
618
|
+
}
|
|
184
619
|
async function main() {
|
|
185
620
|
const [, , sub, ...rest] = process.argv;
|
|
186
|
-
const args = parseArgs(rest);
|
|
621
|
+
const args = cliCommon.parseArgs(rest);
|
|
187
622
|
if (sub === "list") return cmdList(args);
|
|
188
623
|
if (sub === "run") return cmdRun(args);
|
|
624
|
+
if (sub === "can-i-deploy") return cmdCanIDeploy(args);
|
|
625
|
+
if (sub === "report") return cmdReport(args);
|
|
626
|
+
if (sub === "pact-import") return cmdPactImport(args);
|
|
627
|
+
if (sub === "pact-export") return cmdPactExport(args);
|
|
189
628
|
if (args["help"] || !sub) {
|
|
190
629
|
console.log(`
|
|
191
|
-
api-spector contract list
|
|
192
|
-
api-spector contract run
|
|
630
|
+
api-spector contract list --workspace <path>
|
|
631
|
+
api-spector contract run --workspace <path> --mode <consumer|provider|provider-live|bidirectional> [options]
|
|
632
|
+
api-spector contract report --workspace <path> [--html <path>]
|
|
633
|
+
api-spector contract can-i-deploy --workspace <path> --pacticipant <name> --app-version <ver>
|
|
634
|
+
api-spector contract pact-import --file <pact.json> [--out <collection.json>]
|
|
635
|
+
api-spector contract pact-export --workspace <path> --out <pact.json> [--consumer <name> --provider <name> --collection <name>]
|
|
636
|
+
|
|
637
|
+
Modes:
|
|
638
|
+
consumer Send requests to the real provider, assert each response (live).
|
|
639
|
+
provider Static check that requests conform to an OpenAPI spec (no HTTP).
|
|
640
|
+
provider-live Replay consumer contracts against a running provider, with
|
|
641
|
+
provider-state setup. The real Pact-style verification.
|
|
642
|
+
bidirectional Static spec/contract compatibility + live response check.
|
|
193
643
|
|
|
194
644
|
Run options:
|
|
195
645
|
--snapshot <id|name> Pinned snapshot (run list to see IDs)
|
|
@@ -198,7 +648,14 @@ async function main() {
|
|
|
198
648
|
--collection <name> Filter to one collection
|
|
199
649
|
--environment <name> Environment for {{var}} resolution
|
|
200
650
|
--request-base-url <url> Strip this host before matching spec paths
|
|
651
|
+
--provider-base-url <url> (provider-live) rebase requests onto this origin
|
|
652
|
+
--states-url <url> (provider-live) provider state handler endpoint
|
|
201
653
|
--output <path> Write ContractReport JSON here
|
|
654
|
+
--junit <path> Write JUnit XML here (for CI test reporters)
|
|
655
|
+
--html <path> Write a self-contained HTML report here
|
|
656
|
+
--record Record the result for can-i-deploy gating
|
|
657
|
+
--pacticipant <name> Name to record under (default: collection name)
|
|
658
|
+
--app-version <ver> Version to record under (required with --record)
|
|
202
659
|
`);
|
|
203
660
|
return;
|
|
204
661
|
}
|