@testsmith/api-spector 0.4.6 → 0.4.8
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 +2 -1
- package/out/main/chunks/{cli-common-CDQY1erJ.js → cli-common-BKYgsbGB.js} +1 -0
- package/out/main/chunks/{handle-BGYDylL2.js → handle-rimXXdJH.js} +26 -1
- package/out/main/chunks/{import-CUcjlmSK.js → import-DcenB5Q_.js} +2 -2
- package/out/main/chunks/{request-exec-CBi2kL6s.js → request-exec-BH-M3KqZ.js} +201 -6
- package/out/main/chunks/{snapshots-BDe-B5iQ.js → snapshots-CtYxkSLz.js} +556 -6
- package/out/main/chunks/{soap-handler-CAyUjzxB.js → soap-handler-BXx842MN.js} +2 -2
- package/out/main/contract.js +265 -296
- package/out/main/index.js +365 -19
- package/out/main/lib.js +2 -2
- package/out/main/mock.js +1 -1
- package/out/main/record.js +1 -1
- package/out/main/runner.js +4 -4
- package/out/main/wsdl.js +5 -4
- package/out/preload/index.js +45 -1
- package/out/renderer/assets/index-B_xyaTDo.css +2 -0
- package/out/renderer/assets/{index-B55aZQcN.js → index-CEKgq6t0.js} +2609 -787
- package/out/renderer/index.html +2 -2
- package/package.json +8 -5
- package/out/renderer/assets/index-DkZ0bxof.css +0 -2
package/out/main/contract.js
CHANGED
|
@@ -24,15 +24,17 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
));
|
|
25
25
|
const promises = require("fs/promises");
|
|
26
26
|
const path = require("path");
|
|
27
|
-
const snapshots = require("./chunks/snapshots-
|
|
27
|
+
const snapshots = require("./chunks/snapshots-CtYxkSLz.js");
|
|
28
28
|
const crypto = require("crypto");
|
|
29
29
|
const undici = require("undici");
|
|
30
30
|
const os = require("os");
|
|
31
|
-
const cliCommon = require("./chunks/cli-common-
|
|
31
|
+
const cliCommon = require("./chunks/cli-common-BKYgsbGB.js");
|
|
32
|
+
const child_process = require("child_process");
|
|
33
|
+
const jsYaml = require("js-yaml");
|
|
32
34
|
const environments = require("./chunks/environments-iM3SUM-4.js");
|
|
33
|
-
require("./chunks/request-exec-
|
|
35
|
+
require("./chunks/request-exec-BH-M3KqZ.js");
|
|
34
36
|
require("tls");
|
|
35
|
-
require("./chunks/handle-
|
|
37
|
+
require("./chunks/handle-rimXXdJH.js");
|
|
36
38
|
require("dayjs");
|
|
37
39
|
require("vm");
|
|
38
40
|
require("tv4");
|
|
@@ -40,7 +42,6 @@ require("jsonpath-plus");
|
|
|
40
42
|
require("@xmldom/xmldom");
|
|
41
43
|
require("http");
|
|
42
44
|
require("ajv");
|
|
43
|
-
require("js-yaml");
|
|
44
45
|
function escapeXml(s) {
|
|
45
46
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
46
47
|
}
|
|
@@ -72,290 +73,6 @@ function toJUnitXml(report, suiteName = "contract") {
|
|
|
72
73
|
];
|
|
73
74
|
return lines.join("\n") + "\n";
|
|
74
75
|
}
|
|
75
|
-
function parsePath(path2) {
|
|
76
|
-
const tokens = [];
|
|
77
|
-
const re = /\.([^.[\]]+)|\['([^']*)'\]|\[(\d+|\*)\]/g;
|
|
78
|
-
let m;
|
|
79
|
-
while ((m = re.exec(path2)) !== null) {
|
|
80
|
-
if (m[1] !== void 0) tokens.push({ key: m[1] });
|
|
81
|
-
else if (m[2] !== void 0) tokens.push({ key: m[2] });
|
|
82
|
-
else if (m[3] !== void 0) tokens.push({ index: m[3] === "*" ? "*" : Number(m[3]) });
|
|
83
|
-
}
|
|
84
|
-
return tokens;
|
|
85
|
-
}
|
|
86
|
-
function ruleToMatcher(value, rule) {
|
|
87
|
-
switch (rule.match) {
|
|
88
|
-
case "type":
|
|
89
|
-
if (Array.isArray(value)) {
|
|
90
|
-
return { [snapshots.MATCH_KEY]: "eachLike", value: value[0] ?? {}, min: rule.min ?? 1 };
|
|
91
|
-
}
|
|
92
|
-
return { [snapshots.MATCH_KEY]: "type", value };
|
|
93
|
-
case "regex":
|
|
94
|
-
return { [snapshots.MATCH_KEY]: "regex", regex: rule.regex ?? ".*", value };
|
|
95
|
-
case "integer":
|
|
96
|
-
return { [snapshots.MATCH_KEY]: "integer", value };
|
|
97
|
-
case "number":
|
|
98
|
-
case "decimal":
|
|
99
|
-
return { [snapshots.MATCH_KEY]: "decimal", value };
|
|
100
|
-
case "boolean":
|
|
101
|
-
return { [snapshots.MATCH_KEY]: "boolean", value };
|
|
102
|
-
case "null":
|
|
103
|
-
return { [snapshots.MATCH_KEY]: "null", value: null };
|
|
104
|
-
case "datetime":
|
|
105
|
-
case "timestamp":
|
|
106
|
-
return { [snapshots.MATCH_KEY]: "datetime", value, format: rule.format };
|
|
107
|
-
case "date":
|
|
108
|
-
return { [snapshots.MATCH_KEY]: "date", value };
|
|
109
|
-
case "time":
|
|
110
|
-
return { [snapshots.MATCH_KEY]: "time", value };
|
|
111
|
-
default:
|
|
112
|
-
return value;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
function applyAtPath(root, tokens, transform) {
|
|
116
|
-
if (tokens.length === 0) return transform(root);
|
|
117
|
-
const [head, ...rest] = tokens;
|
|
118
|
-
if ("index" in head) {
|
|
119
|
-
if (!Array.isArray(root)) return root;
|
|
120
|
-
if (head.index === "*") {
|
|
121
|
-
return root.map((el) => applyAtPath(el, rest, transform));
|
|
122
|
-
}
|
|
123
|
-
const arr = [...root];
|
|
124
|
-
if (head.index < arr.length) arr[head.index] = applyAtPath(arr[head.index], rest, transform);
|
|
125
|
-
return arr;
|
|
126
|
-
}
|
|
127
|
-
if (typeof root !== "object" || root === null || Array.isArray(root)) return root;
|
|
128
|
-
const obj = { ...root };
|
|
129
|
-
if (head.key in obj) obj[head.key] = applyAtPath(obj[head.key], rest, transform);
|
|
130
|
-
return obj;
|
|
131
|
-
}
|
|
132
|
-
function bodyWithMatchers(body, matchingRules) {
|
|
133
|
-
const bodyRules = matchingRules?.["body"] ?? matchingRules?.["content"];
|
|
134
|
-
if (!bodyRules || body === void 0) return body;
|
|
135
|
-
const entries = Object.entries(bodyRules).sort((a, b) => parsePath(b[0]).length - parsePath(a[0]).length);
|
|
136
|
-
let result = body;
|
|
137
|
-
for (const [path2, def] of entries) {
|
|
138
|
-
const rule = def?.matchers?.[0];
|
|
139
|
-
if (!rule) continue;
|
|
140
|
-
const tokens = parsePath(path2);
|
|
141
|
-
result = applyAtPath(result, tokens, (v) => ruleToMatcher(v, rule));
|
|
142
|
-
}
|
|
143
|
-
return result;
|
|
144
|
-
}
|
|
145
|
-
function providerStatesOf(interaction) {
|
|
146
|
-
const v3 = interaction["providerStates"];
|
|
147
|
-
if (Array.isArray(v3)) return v3.map((s) => s?.name ?? "").filter(Boolean);
|
|
148
|
-
const v2 = interaction["providerState"] ?? interaction["provider_state"];
|
|
149
|
-
return typeof v2 === "string" && v2 ? [v2] : [];
|
|
150
|
-
}
|
|
151
|
-
function queryToParams(query) {
|
|
152
|
-
const params = [];
|
|
153
|
-
if (!query) return params;
|
|
154
|
-
if (typeof query === "string") {
|
|
155
|
-
for (const pair of query.split("&")) {
|
|
156
|
-
const [k, v = ""] = pair.split("=");
|
|
157
|
-
if (k) params.push({ key: decodeURIComponent(k), value: decodeURIComponent(v), enabled: true });
|
|
158
|
-
}
|
|
159
|
-
} else if (typeof query === "object") {
|
|
160
|
-
for (const [k, vals] of Object.entries(query)) {
|
|
161
|
-
const list = Array.isArray(vals) ? vals : [vals];
|
|
162
|
-
for (const v of list) params.push({ key: k, value: String(v ?? ""), enabled: true });
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return params;
|
|
166
|
-
}
|
|
167
|
-
function headerEntries(headers) {
|
|
168
|
-
if (!headers || typeof headers !== "object") return [];
|
|
169
|
-
return Object.entries(headers).map(([key, value]) => ({
|
|
170
|
-
key,
|
|
171
|
-
value: Array.isArray(value) ? value.join(", ") : String(value ?? "")
|
|
172
|
-
}));
|
|
173
|
-
}
|
|
174
|
-
function headersToKv(headers) {
|
|
175
|
-
return headerEntries(headers).map((h) => ({ ...h, enabled: true }));
|
|
176
|
-
}
|
|
177
|
-
function headersToContract(headers) {
|
|
178
|
-
return headerEntries(headers).map((h) => ({ ...h, required: true }));
|
|
179
|
-
}
|
|
180
|
-
function importInteraction(interaction) {
|
|
181
|
-
const request = interaction["request"] ?? {};
|
|
182
|
-
const response = interaction["response"] ?? {};
|
|
183
|
-
const method = String(request["method"] ?? "GET").toUpperCase();
|
|
184
|
-
const path2 = String(request["path"] ?? "/");
|
|
185
|
-
const reqBody = request["body"];
|
|
186
|
-
const body = reqBody === void 0 ? { mode: "none" } : typeof reqBody === "string" ? { mode: "raw", raw: reqBody } : { mode: "json", json: JSON.stringify(reqBody, null, 2) };
|
|
187
|
-
const contract = {};
|
|
188
|
-
if (typeof response["status"] === "number") contract.statusCode = response["status"];
|
|
189
|
-
const respHeaders = headersToContract(response["headers"]);
|
|
190
|
-
if (respHeaders?.length) contract.headers = respHeaders;
|
|
191
|
-
if (response["body"] !== void 0) {
|
|
192
|
-
const example = bodyWithMatchers(response["body"], response["matchingRules"]);
|
|
193
|
-
contract.bodyMatcher = JSON.stringify(example, null, 2);
|
|
194
|
-
}
|
|
195
|
-
const states = providerStatesOf(interaction);
|
|
196
|
-
if (states.length) contract.providerStates = states;
|
|
197
|
-
return {
|
|
198
|
-
id: crypto.randomUUID(),
|
|
199
|
-
name: String(interaction["description"] ?? `${method} ${path2}`),
|
|
200
|
-
method,
|
|
201
|
-
url: `{{baseUrl}}${path2}`,
|
|
202
|
-
headers: headersToKv(request["headers"]),
|
|
203
|
-
params: queryToParams(request["query"]),
|
|
204
|
-
auth: { type: "none" },
|
|
205
|
-
body,
|
|
206
|
-
contract,
|
|
207
|
-
meta: { tags: ["pact"] }
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
function importPact(json) {
|
|
211
|
-
const pact = typeof json === "string" ? JSON.parse(json) : json;
|
|
212
|
-
const consumer = pact["consumer"]?.name ?? "consumer";
|
|
213
|
-
const provider = pact["provider"]?.name ?? "provider";
|
|
214
|
-
const metadata = pact["metadata"];
|
|
215
|
-
const specVersion = String(
|
|
216
|
-
metadata?.["pactSpecification"]?.version ?? metadata?.["pact-specification"]?.version ?? "3.0.0"
|
|
217
|
-
);
|
|
218
|
-
const interactions = pact["interactions"] ?? [];
|
|
219
|
-
const httpInteractions = interactions.filter((i) => {
|
|
220
|
-
const type = i["type"];
|
|
221
|
-
return type === void 0 || type === "Synchronous/HTTP" || type === "HTTP";
|
|
222
|
-
});
|
|
223
|
-
return {
|
|
224
|
-
consumer,
|
|
225
|
-
provider,
|
|
226
|
-
specVersion,
|
|
227
|
-
requests: httpInteractions.map(importInteraction)
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
function pactToCollection(result) {
|
|
231
|
-
const requests = {};
|
|
232
|
-
for (const r of result.requests) requests[r.id] = r;
|
|
233
|
-
return {
|
|
234
|
-
version: "1.0",
|
|
235
|
-
id: crypto.randomUUID(),
|
|
236
|
-
name: `${result.consumer} → ${result.provider}`,
|
|
237
|
-
description: `Imported from Pact (spec ${result.specVersion})`,
|
|
238
|
-
rootFolder: {
|
|
239
|
-
id: crypto.randomUUID(),
|
|
240
|
-
name: "root",
|
|
241
|
-
folders: [],
|
|
242
|
-
requestIds: result.requests.map((r) => r.id)
|
|
243
|
-
},
|
|
244
|
-
requests,
|
|
245
|
-
collectionVariables: { baseUrl: "" }
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
function isMatcher(node) {
|
|
249
|
-
return typeof node === "object" && node !== null && !Array.isArray(node) && typeof node[snapshots.MATCH_KEY] === "string";
|
|
250
|
-
}
|
|
251
|
-
function exampleToPactBody(node) {
|
|
252
|
-
const rules = {};
|
|
253
|
-
function walk(n, path2) {
|
|
254
|
-
if (isMatcher(n)) {
|
|
255
|
-
const kind = n[snapshots.MATCH_KEY];
|
|
256
|
-
switch (kind) {
|
|
257
|
-
case "type":
|
|
258
|
-
rules[path2] = { matchers: [{ match: "type" }] };
|
|
259
|
-
return walk(n.value, path2);
|
|
260
|
-
case "eachLike": {
|
|
261
|
-
rules[path2] = { matchers: [{ match: "type", min: n.min ?? 1 }] };
|
|
262
|
-
return [walk(n.value, `${path2}[*]`)];
|
|
263
|
-
}
|
|
264
|
-
case "regex":
|
|
265
|
-
rules[path2] = { matchers: [{ match: "regex", regex: n.regex ?? ".*" }] };
|
|
266
|
-
return n.value ?? "";
|
|
267
|
-
case "integer":
|
|
268
|
-
rules[path2] = { matchers: [{ match: "integer" }] };
|
|
269
|
-
return n.value ?? 0;
|
|
270
|
-
case "decimal":
|
|
271
|
-
case "number":
|
|
272
|
-
rules[path2] = { matchers: [{ match: "decimal" }] };
|
|
273
|
-
return n.value ?? 0;
|
|
274
|
-
case "boolean":
|
|
275
|
-
rules[path2] = { matchers: [{ match: "boolean" }] };
|
|
276
|
-
return n.value ?? false;
|
|
277
|
-
case "string":
|
|
278
|
-
rules[path2] = { matchers: [{ match: "type" }] };
|
|
279
|
-
return n.value ?? "";
|
|
280
|
-
case "null":
|
|
281
|
-
rules[path2] = { matchers: [{ match: "null" }] };
|
|
282
|
-
return null;
|
|
283
|
-
case "datetime":
|
|
284
|
-
case "timestamp":
|
|
285
|
-
rules[path2] = { matchers: [{ match: "datetime", format: n.format }] };
|
|
286
|
-
return n.value ?? "";
|
|
287
|
-
case "date":
|
|
288
|
-
rules[path2] = { matchers: [{ match: "date" }] };
|
|
289
|
-
return n.value ?? "";
|
|
290
|
-
case "time":
|
|
291
|
-
rules[path2] = { matchers: [{ match: "time" }] };
|
|
292
|
-
return n.value ?? "";
|
|
293
|
-
default:
|
|
294
|
-
return n.value;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
if (Array.isArray(n)) return n.map((el, i) => walk(el, `${path2}[${i}]`));
|
|
298
|
-
if (typeof n === "object" && n !== null) {
|
|
299
|
-
const out = {};
|
|
300
|
-
for (const [k, v] of Object.entries(n)) out[k] = walk(v, `${path2}.${k}`);
|
|
301
|
-
return out;
|
|
302
|
-
}
|
|
303
|
-
return n;
|
|
304
|
-
}
|
|
305
|
-
const body = walk(node, "$");
|
|
306
|
-
return { body, rules };
|
|
307
|
-
}
|
|
308
|
-
function kvToHeaders(kv) {
|
|
309
|
-
if (!kv?.length) return void 0;
|
|
310
|
-
const out = {};
|
|
311
|
-
for (const h of kv) if (h.enabled !== false && h.key) out[h.key] = h.value;
|
|
312
|
-
return Object.keys(out).length ? out : void 0;
|
|
313
|
-
}
|
|
314
|
-
function exportPact(consumer, provider, requests) {
|
|
315
|
-
const interactions = requests.filter((r) => r.contract).map((r) => {
|
|
316
|
-
const c = r.contract;
|
|
317
|
-
const path2 = r.url.replace(/^\{\{baseUrl\}\}/, "").replace(/^https?:\/\/[^/]+/, "") || "/";
|
|
318
|
-
const query = {};
|
|
319
|
-
for (const p of r.params ?? []) {
|
|
320
|
-
if (p.enabled === false || !p.key) continue;
|
|
321
|
-
(query[p.key] ??= []).push(p.value);
|
|
322
|
-
}
|
|
323
|
-
const response = {};
|
|
324
|
-
if (c.statusCode !== void 0) response["status"] = c.statusCode;
|
|
325
|
-
const respHeaders = kvToHeaders(c.headers);
|
|
326
|
-
if (respHeaders) response["headers"] = respHeaders;
|
|
327
|
-
if (c.bodyMatcher?.trim()) {
|
|
328
|
-
try {
|
|
329
|
-
const { body, rules } = exampleToPactBody(JSON.parse(c.bodyMatcher));
|
|
330
|
-
response["body"] = body;
|
|
331
|
-
if (Object.keys(rules).length) response["matchingRules"] = { body: rules };
|
|
332
|
-
} catch {
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
const request = { method: r.method, path: path2 };
|
|
336
|
-
if (Object.keys(query).length) request["query"] = query;
|
|
337
|
-
const reqHeaders = kvToHeaders(r.headers);
|
|
338
|
-
if (reqHeaders) request["headers"] = reqHeaders;
|
|
339
|
-
if (r.body?.mode === "json" && r.body.json) {
|
|
340
|
-
try {
|
|
341
|
-
request["body"] = JSON.parse(r.body.json);
|
|
342
|
-
} catch {
|
|
343
|
-
}
|
|
344
|
-
} else if (r.body?.mode === "raw" && r.body.raw) request["body"] = r.body.raw;
|
|
345
|
-
const interaction = { description: r.name, request, response };
|
|
346
|
-
if (c.providerStates?.length) interaction["providerStates"] = c.providerStates.map((name) => ({ name }));
|
|
347
|
-
return interaction;
|
|
348
|
-
});
|
|
349
|
-
return {
|
|
350
|
-
consumer: { name: consumer },
|
|
351
|
-
provider: { name: provider },
|
|
352
|
-
interactions,
|
|
353
|
-
metadata: {
|
|
354
|
-
pactSpecification: { version: "3.0.0" },
|
|
355
|
-
client: { name: "api-spector" }
|
|
356
|
-
}
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
76
|
const PENDING_FILE = "contracts/pending.json";
|
|
360
77
|
function interactionKey(req) {
|
|
361
78
|
const contractHash = crypto.createHash("sha256").update(JSON.stringify(req.contract ?? {})).digest("hex").slice(0, 16);
|
|
@@ -499,6 +216,127 @@ function watchContractEvents(dir, hooks, intervalMs = 1e4, log = console.log) {
|
|
|
499
216
|
}, intervalMs);
|
|
500
217
|
return () => clearInterval(timer);
|
|
501
218
|
}
|
|
219
|
+
function brokerConfigFromEnv() {
|
|
220
|
+
return {
|
|
221
|
+
endpoint: (process.env["API_SPECTOR_CLOUD_ENDPOINT"] || "https://api-spector.dev").replace(/\/+$/, ""),
|
|
222
|
+
token: process.env["API_SPECTOR_TOKEN"] || ""
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
async function brokerFetch(cfg, path2, method, body) {
|
|
226
|
+
if (!cfg.token) throw new Error("No API token. Set API_SPECTOR_TOKEN (create one in the cloud dashboard under Tokens).");
|
|
227
|
+
const url = cfg.endpoint + path2;
|
|
228
|
+
let res;
|
|
229
|
+
try {
|
|
230
|
+
res = await undici.fetch(url, {
|
|
231
|
+
method,
|
|
232
|
+
headers: {
|
|
233
|
+
"Content-Type": "application/json",
|
|
234
|
+
Accept: "application/json",
|
|
235
|
+
Authorization: `Bearer ${cfg.token}`
|
|
236
|
+
},
|
|
237
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
238
|
+
});
|
|
239
|
+
} catch (err) {
|
|
240
|
+
throw new Error(`Could not reach ${url}: ${err.message}`);
|
|
241
|
+
}
|
|
242
|
+
const text = await res.text();
|
|
243
|
+
let json;
|
|
244
|
+
try {
|
|
245
|
+
json = text ? JSON.parse(text) : {};
|
|
246
|
+
} catch {
|
|
247
|
+
json = { raw: text };
|
|
248
|
+
}
|
|
249
|
+
return { status: res.status, ok: res.ok, json };
|
|
250
|
+
}
|
|
251
|
+
function fail(r, what) {
|
|
252
|
+
const detail = r.json?.error || r.json?.message || (r.status === 401 ? "Unauthorized (check API_SPECTOR_TOKEN)" : `HTTP ${r.status}`);
|
|
253
|
+
throw new Error(`${what}: ${detail}`);
|
|
254
|
+
}
|
|
255
|
+
async function publishPact(cfg, input) {
|
|
256
|
+
const r = await brokerFetch(cfg, "/api/contracts", "PUT", {
|
|
257
|
+
consumer: input.consumer,
|
|
258
|
+
consumerVersion: input.consumerVersion,
|
|
259
|
+
provider: input.provider,
|
|
260
|
+
content: input.pact
|
|
261
|
+
});
|
|
262
|
+
if (!r.ok) fail(r, "Publish pact failed");
|
|
263
|
+
}
|
|
264
|
+
async function publishSpec(cfg, input) {
|
|
265
|
+
const r = await brokerFetch(cfg, "/api/provider-contracts", "PUT", {
|
|
266
|
+
pacticipant: input.pacticipant,
|
|
267
|
+
version: input.version,
|
|
268
|
+
spec: input.spec,
|
|
269
|
+
// Optional provider self-verification (its own tests run against this spec).
|
|
270
|
+
...input.results ? { results: input.results } : {}
|
|
271
|
+
});
|
|
272
|
+
if (!r.ok) fail(r, "Publish spec failed");
|
|
273
|
+
}
|
|
274
|
+
async function canIDeploy(cfg, input) {
|
|
275
|
+
const q = new URLSearchParams({ pacticipant: input.pacticipant, version: input.version, environment: input.environment }).toString();
|
|
276
|
+
const r = await brokerFetch(cfg, "/can-i-deploy?" + q, "GET");
|
|
277
|
+
if (r.status !== 200 && r.status !== 409) fail(r, "can-i-deploy failed");
|
|
278
|
+
return { deployable: r.status === 200, reason: r.json?.summary?.reason || "" };
|
|
279
|
+
}
|
|
280
|
+
async function deployPreview(cfg, input) {
|
|
281
|
+
const q = new URLSearchParams({ pacticipant: input.pacticipant, version: input.version, environment: input.environment }).toString();
|
|
282
|
+
const r = await brokerFetch(cfg, "/api/deploy-preview?" + q, "GET");
|
|
283
|
+
if (!r.ok) fail(r, "deploy-preview failed");
|
|
284
|
+
return r.json;
|
|
285
|
+
}
|
|
286
|
+
async function recordDeployment(cfg, input) {
|
|
287
|
+
const path2 = `/pacticipants/${encodeURIComponent(input.pacticipant)}/versions/${encodeURIComponent(input.version)}/deployed-versions/environment/${encodeURIComponent(input.environment)}`;
|
|
288
|
+
const r = await brokerFetch(cfg, path2, "POST", {});
|
|
289
|
+
if (!r.ok) fail(r, "record-deployment failed");
|
|
290
|
+
}
|
|
291
|
+
function resolveVersion(override) {
|
|
292
|
+
if (override) return override;
|
|
293
|
+
const env = process.env["GITHUB_SHA"] || process.env["CI_COMMIT_SHA"] || process.env["GIT_COMMIT"] || process.env["CIRCLE_SHA1"] || process.env["BUILD_SOURCEVERSION"] || process.env["BITBUCKET_COMMIT"];
|
|
294
|
+
if (env) return env.trim();
|
|
295
|
+
try {
|
|
296
|
+
return child_process.execSync("git rev-parse HEAD", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
297
|
+
} catch {
|
|
298
|
+
throw new Error("Could not resolve a version. Pass --version <sha>, or run inside a git repo / CI.");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function parseSelfVerification(text, path2) {
|
|
302
|
+
const trimmed = text.trimStart();
|
|
303
|
+
const isXml = path2.toLowerCase().endsWith(".xml") || trimmed.startsWith("<");
|
|
304
|
+
if (isXml) {
|
|
305
|
+
const num = (tag, name) => {
|
|
306
|
+
const m = tag.match(new RegExp(`\\b${name}="(\\d+(?:\\.\\d+)?)"`));
|
|
307
|
+
return m ? Math.round(Number(m[1])) : 0;
|
|
308
|
+
};
|
|
309
|
+
const agg = text.match(/<testsuites\b[^>]*>/);
|
|
310
|
+
let tests = 0, failures = 0, errors = 0;
|
|
311
|
+
if (agg) {
|
|
312
|
+
tests = num(agg[0], "tests");
|
|
313
|
+
failures = num(agg[0], "failures");
|
|
314
|
+
errors = num(agg[0], "errors");
|
|
315
|
+
}
|
|
316
|
+
if (!tests) {
|
|
317
|
+
for (const t of text.match(/<testsuite\b[^>]*>/g) ?? []) {
|
|
318
|
+
tests += num(t, "tests");
|
|
319
|
+
failures += num(t, "failures");
|
|
320
|
+
errors += num(t, "errors");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const failed2 = failures + errors;
|
|
324
|
+
return { success: failed2 === 0, total: tests, passed: tests - failed2, failed: failed2, source: "junit" };
|
|
325
|
+
}
|
|
326
|
+
let o;
|
|
327
|
+
try {
|
|
328
|
+
o = JSON.parse(text);
|
|
329
|
+
} catch {
|
|
330
|
+
throw new Error("Could not parse the results file (expected JUnit XML or JSON).");
|
|
331
|
+
}
|
|
332
|
+
if (typeof o.success === "boolean") return o;
|
|
333
|
+
const total = o.total ?? o.tests;
|
|
334
|
+
const failed = o.failed ?? o.failures ?? 0;
|
|
335
|
+
if (typeof total === "number") {
|
|
336
|
+
return { success: failed === 0, total, passed: total - failed, failed, source: o.source ?? "json" };
|
|
337
|
+
}
|
|
338
|
+
throw new Error("Could not read the results file: JSON needs a boolean `success` or a numeric `total`/`tests`.");
|
|
339
|
+
}
|
|
502
340
|
async function cmdList(args) {
|
|
503
341
|
const wsArg = args["workspace"];
|
|
504
342
|
if (typeof wsArg !== "string") {
|
|
@@ -587,7 +425,17 @@ async function cmdRun(args) {
|
|
|
587
425
|
const collectionVars = {};
|
|
588
426
|
for (const c of collections) Object.assign(collectionVars, c.collectionVariables ?? {});
|
|
589
427
|
const allRequests = collections.flatMap((c) => Object.values(c.requests));
|
|
590
|
-
const
|
|
428
|
+
const collectionContracts = allRequests.filter((r) => snapshots.hasContract(r.contract));
|
|
429
|
+
const keyOf = (r) => `${r.method} ${r.url} ${r.name}`;
|
|
430
|
+
const collectionKeys = new Set(collectionContracts.map(keyOf));
|
|
431
|
+
const designContracts = (await snapshots.loadDesignContractRequests(workspace, dir)).filter((r) => !collectionKeys.has(keyOf(r)));
|
|
432
|
+
const contractRequests = [...collectionContracts, ...designContracts];
|
|
433
|
+
if (designContracts.length) {
|
|
434
|
+
console.log(` + ${designContracts.length} design-first interaction(s) from the Contract Designer / pacts/`);
|
|
435
|
+
if (envVars["baseUrl"] === void 0 && collectionVars["baseUrl"] === void 0) {
|
|
436
|
+
collectionVars["baseUrl"] = "";
|
|
437
|
+
}
|
|
438
|
+
}
|
|
591
439
|
let specUrl = typeof args["spec-url"] === "string" ? args["spec-url"] : void 0;
|
|
592
440
|
let specPath = typeof args["spec-path"] === "string" ? args["spec-path"] : void 0;
|
|
593
441
|
let snapshotLabel;
|
|
@@ -987,10 +835,10 @@ async function cmdPactImport(args) {
|
|
|
987
835
|
console.error(" [error] --file <pact.json> is required");
|
|
988
836
|
process.exit(2);
|
|
989
837
|
}
|
|
990
|
-
const result = importPact(await promises.readFile(file, "utf8"));
|
|
838
|
+
const result = snapshots.importPact(await promises.readFile(file, "utf8"));
|
|
991
839
|
console.log(` Imported ${result.requests.length} interaction(s): ${result.consumer} → ${result.provider} (spec ${result.specVersion})`);
|
|
992
840
|
if (typeof args["out"] === "string") {
|
|
993
|
-
const collection = pactToCollection(result);
|
|
841
|
+
const collection = snapshots.pactToCollection(result);
|
|
994
842
|
await promises.writeFile(args["out"], JSON.stringify(collection, null, 2), "utf8");
|
|
995
843
|
console.log(` Collection written to ${args["out"]}`);
|
|
996
844
|
console.log(` Set the "baseUrl" collection variable, then verify with:`);
|
|
@@ -1018,18 +866,132 @@ async function cmdPactExport(args) {
|
|
|
1018
866
|
}
|
|
1019
867
|
const consumer = typeof args["consumer"] === "string" ? args["consumer"] : collections[0]?.name ?? "consumer";
|
|
1020
868
|
const provider = typeof args["provider"] === "string" ? args["provider"] : "provider";
|
|
1021
|
-
const pact = exportPact(consumer, provider, requests);
|
|
869
|
+
const pact = snapshots.exportPact(consumer, provider, requests);
|
|
1022
870
|
await promises.writeFile(out, JSON.stringify(pact, null, 2), "utf8");
|
|
1023
871
|
console.log(` Exported ${requests.length} interaction(s) to ${out} (${consumer} → ${provider})`);
|
|
1024
872
|
}
|
|
873
|
+
const str = (v) => typeof v === "string" ? v : void 0;
|
|
874
|
+
const die = (msg) => {
|
|
875
|
+
console.error(` [error] ${msg}`);
|
|
876
|
+
process.exit(2);
|
|
877
|
+
};
|
|
878
|
+
function wantsCloud(args) {
|
|
879
|
+
return !!args["broker"] || !!process.env["API_SPECTOR_TOKEN"] && !args["workspace"];
|
|
880
|
+
}
|
|
881
|
+
function deriveContract(req) {
|
|
882
|
+
const ex = (req.examples ?? []).find((e) => e.response && typeof e.response.status === "number");
|
|
883
|
+
if (!ex?.response) return void 0;
|
|
884
|
+
const c = { statusCode: ex.response.status };
|
|
885
|
+
try {
|
|
886
|
+
const body = JSON.parse(ex.response.body || "null");
|
|
887
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
888
|
+
const jsType = (v) => Array.isArray(v) ? "array" : v === null ? "null" : typeof v === "number" ? Number.isInteger(v) ? "integer" : "number" : typeof v === "boolean" ? "boolean" : typeof v === "object" ? "object" : "string";
|
|
889
|
+
c.bodySchema = JSON.stringify({
|
|
890
|
+
type: "object",
|
|
891
|
+
required: Object.keys(body),
|
|
892
|
+
properties: Object.fromEntries(Object.entries(body).map(([k, v]) => [k, { type: jsType(v) }]))
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
} catch {
|
|
896
|
+
}
|
|
897
|
+
return c;
|
|
898
|
+
}
|
|
899
|
+
async function cmdPublish(args) {
|
|
900
|
+
const wsArg = str(args["workspace"]) ?? die("--workspace <path> is required");
|
|
901
|
+
const consumer = str(args["consumer"]) ?? die("--consumer <name> is required");
|
|
902
|
+
const provider = str(args["provider"]) ?? die("--provider <name> is required");
|
|
903
|
+
const version = resolveVersion(str(args["version"]));
|
|
904
|
+
const { workspace, dir } = await cliCommon.loadWorkspace(wsArg);
|
|
905
|
+
const collections = await cliCommon.loadCollections(workspace, dir, { filterName: str(args["collection"]) });
|
|
906
|
+
let all = collections.flatMap((c) => Object.values(c.requests));
|
|
907
|
+
const tag = str(args["tag"]);
|
|
908
|
+
if (tag) all = all.filter((r) => (r.meta?.tags ?? []).includes(tag));
|
|
909
|
+
let requests = all.filter((r) => snapshots.hasContract(r.contract));
|
|
910
|
+
if (args["derive"]) {
|
|
911
|
+
const derived = all.filter((r) => !snapshots.hasContract(r.contract)).map((r) => ({ ...r, contract: deriveContract(r) })).filter((r) => snapshots.hasContract(r.contract));
|
|
912
|
+
requests = [...requests, ...derived];
|
|
913
|
+
}
|
|
914
|
+
if (requests.length === 0) die("No requests with contract expectations found to publish (set them on the Contract tab, or pass --derive to use saved example responses).");
|
|
915
|
+
const pact = snapshots.exportPact(consumer, provider, requests);
|
|
916
|
+
await publishPact(brokerConfigFromEnv(), { consumer, provider, consumerVersion: version, pact });
|
|
917
|
+
console.log(` Published ${requests.length} interaction(s): ${consumer}@${version.slice(0, 7)} → ${provider}`);
|
|
918
|
+
}
|
|
919
|
+
async function cmdPublishSpecLocal(args) {
|
|
920
|
+
const norm = { ...args };
|
|
921
|
+
if (typeof norm["spec"] === "string" && typeof norm["spec-path"] !== "string") norm["spec-path"] = norm["spec"];
|
|
922
|
+
if (typeof norm["provider"] === "string" && typeof norm["name"] !== "string") norm["name"] = norm["provider"];
|
|
923
|
+
return cmdPin(norm);
|
|
924
|
+
}
|
|
925
|
+
async function cmdPublishSpec(args) {
|
|
926
|
+
const pacticipant = str(args["provider"]) ?? str(args["pacticipant"]) ?? die("--provider <name> is required");
|
|
927
|
+
const version = resolveVersion(str(args["version"]));
|
|
928
|
+
let specText;
|
|
929
|
+
const specUrl = str(args["spec-url"]);
|
|
930
|
+
const specPath = str(args["spec"]) ?? str(args["spec-path"]);
|
|
931
|
+
if (specUrl) {
|
|
932
|
+
const res = await undici.fetch(specUrl);
|
|
933
|
+
if (!res.ok) die(`Could not fetch spec from ${specUrl} (HTTP ${res.status})`);
|
|
934
|
+
specText = await res.text();
|
|
935
|
+
} else if (specPath) {
|
|
936
|
+
specText = await promises.readFile(specPath, "utf8");
|
|
937
|
+
} else {
|
|
938
|
+
return die("--spec <path> or --spec-url <url> is required");
|
|
939
|
+
}
|
|
940
|
+
let spec;
|
|
941
|
+
try {
|
|
942
|
+
spec = JSON.parse(specText);
|
|
943
|
+
} catch {
|
|
944
|
+
spec = jsYaml.load(specText);
|
|
945
|
+
}
|
|
946
|
+
if (!spec || typeof spec !== "object") die("Could not parse the OpenAPI spec (expected JSON or YAML).");
|
|
947
|
+
let results;
|
|
948
|
+
const resultsPath = str(args["results"]);
|
|
949
|
+
if (resultsPath) results = parseSelfVerification(await promises.readFile(resultsPath, "utf8"), resultsPath);
|
|
950
|
+
await publishSpec(brokerConfigFromEnv(), { pacticipant, version, spec, results });
|
|
951
|
+
const selfNote = results ? ` (self-verification: ${results.success ? "passed" : "FAILED"})` : "";
|
|
952
|
+
console.log(` Published spec: ${pacticipant}@${version.slice(0, 7)}${selfNote}`);
|
|
953
|
+
}
|
|
954
|
+
async function cmdCloudCanIDeploy(args) {
|
|
955
|
+
const pacticipant = str(args["pacticipant"]) ?? die("--pacticipant <name> is required");
|
|
956
|
+
const version = resolveVersion(str(args["version"]));
|
|
957
|
+
const environment = str(args["environment"]) ?? str(args["to"]) ?? die("--environment <name> is required");
|
|
958
|
+
const { deployable, reason } = await canIDeploy(brokerConfigFromEnv(), { pacticipant, version, environment });
|
|
959
|
+
if (deployable) {
|
|
960
|
+
console.log(` ✓ ${pacticipant}@${version.slice(0, 7)} can deploy to ${environment}`);
|
|
961
|
+
} else {
|
|
962
|
+
console.error(` ✗ ${pacticipant}@${version.slice(0, 7)} cannot deploy to ${environment}`);
|
|
963
|
+
if (reason) console.error(` ${reason}`);
|
|
964
|
+
process.exit(1);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
async function cmdPreview(args) {
|
|
968
|
+
const pacticipant = str(args["pacticipant"]) ?? die("--pacticipant <name> is required");
|
|
969
|
+
const version = resolveVersion(str(args["version"]));
|
|
970
|
+
const environment = str(args["environment"]) ?? str(args["to"]) ?? die("--environment <name> is required");
|
|
971
|
+
const preview = await deployPreview(brokerConfigFromEnv(), { pacticipant, version, environment });
|
|
972
|
+
console.log(`### ${preview.check.title}
|
|
973
|
+
|
|
974
|
+
${preview.check.summary}`);
|
|
975
|
+
if (!preview.deployable && args["fail-on-break"]) process.exit(1);
|
|
976
|
+
}
|
|
977
|
+
async function cmdCloudRecordDeployment(args) {
|
|
978
|
+
const pacticipant = str(args["pacticipant"]) ?? die("--pacticipant <name> is required");
|
|
979
|
+
const version = resolveVersion(str(args["version"]));
|
|
980
|
+
const environment = str(args["environment"]) ?? str(args["env"]) ?? die("--environment <name> is required");
|
|
981
|
+
await recordDeployment(brokerConfigFromEnv(), { pacticipant, version, environment });
|
|
982
|
+
console.log(` Recorded ${pacticipant}@${version.slice(0, 7)} deployed to ${environment}`);
|
|
983
|
+
}
|
|
1025
984
|
async function main() {
|
|
1026
985
|
const [, , sub, ...rest] = process.argv;
|
|
1027
986
|
const args = cliCommon.parseArgs(rest);
|
|
1028
987
|
if (sub === "list") return cmdList(args);
|
|
1029
988
|
if (sub === "pin") return cmdPin(args);
|
|
1030
989
|
if (sub === "run") return cmdRun(args);
|
|
1031
|
-
if (sub === "
|
|
1032
|
-
if (sub === "
|
|
990
|
+
if (sub === "publish") return cmdPublish(args);
|
|
991
|
+
if (sub === "publish-spec") return wantsCloud(args) ? cmdPublishSpec(args) : cmdPublishSpecLocal(args);
|
|
992
|
+
if (sub === "preview") return cmdPreview(args);
|
|
993
|
+
if (sub === "deploy-check" || sub === "can-i-deploy") return wantsCloud(args) ? cmdCloudCanIDeploy(args) : cmdCanIDeploy(args);
|
|
994
|
+
if (sub === "record-deployment") return wantsCloud(args) ? cmdCloudRecordDeployment(args) : cmdRecordDeployment(args);
|
|
1033
995
|
if (sub === "environments") return cmdEnvironments(args);
|
|
1034
996
|
if (sub === "webhooks") return cmdWebhooks(args);
|
|
1035
997
|
if (sub === "fuzz") return cmdFuzz(args);
|
|
@@ -1042,7 +1004,7 @@ async function main() {
|
|
|
1042
1004
|
api-spector contract pin --workspace <path> --spec-url <url> | --spec-path <file> [--name <label>]
|
|
1043
1005
|
api-spector contract run --workspace <path> --mode <consumer|provider|provider-live|bidirectional> [options]
|
|
1044
1006
|
api-spector contract report --workspace <path> [--html <path>] [--serve [--port <n>]]
|
|
1045
|
-
api-spector contract
|
|
1007
|
+
api-spector contract deploy-check --workspace <path> --pacticipant <name> --app-version <ver> [--to <env>]
|
|
1046
1008
|
api-spector contract record-deployment --workspace <path> --pacticipant <name> --app-version <ver> --env <name>
|
|
1047
1009
|
api-spector contract environments --workspace <path>
|
|
1048
1010
|
api-spector contract webhooks --workspace <path> [--test]
|
|
@@ -1050,6 +1012,13 @@ async function main() {
|
|
|
1050
1012
|
api-spector contract pact-import --file <pact.json> [--out <collection.json>]
|
|
1051
1013
|
api-spector contract pact-export --workspace <path> --out <pact.json> [--consumer <name> --provider <name> --collection <name>]
|
|
1052
1014
|
|
|
1015
|
+
Cloud broker (git-native; auth via API_SPECTOR_TOKEN, version = git SHA):
|
|
1016
|
+
api-spector contract publish --workspace <path> --consumer <name> --provider <name> [--tag <folder>] [--version <sha>]
|
|
1017
|
+
api-spector contract publish-spec --provider <name> --spec <file> | --spec-url <url> [--broker] [--version <sha>]
|
|
1018
|
+
# --broker → cloud; otherwise pins into --workspace (local / git)
|
|
1019
|
+
api-spector contract deploy-check --broker --pacticipant <name> --environment <env> [--version <sha>] # exit 1 = blocked (alias: can-i-deploy)
|
|
1020
|
+
api-spector contract record-deployment --broker --pacticipant <name> --environment <env> [--version <sha>]
|
|
1021
|
+
|
|
1053
1022
|
Modes:
|
|
1054
1023
|
consumer Send requests to the real provider, assert each response (live).
|
|
1055
1024
|
provider Static check that requests conform to an OpenAPI spec (no HTTP).
|