@testsmith/api-spector 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-CQliv7WB.js");
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-yaml");
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
- function parseArgs(argv) {
16
- const args = {};
17
- for (let i = 0; i < argv.length; i++) {
18
- const arg = argv[i];
19
- if (arg.startsWith("--")) {
20
- const key = arg.slice(2);
21
- const next = argv[i + 1];
22
- if (!next || next.startsWith("--")) {
23
- args[key] = true;
24
- } else {
25
- args[key] = next;
26
- i++;
27
- }
28
- }
15
+ require("js-yaml");
16
+ require("ajv");
17
+ function escapeXml(s) {
18
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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
- return args;
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
- async function resolveWorkspacePath(wsPath) {
33
- const s = await promises.stat(wsPath);
34
- if (!s.isDirectory()) return wsPath;
35
- const entries = await promises.readdir(wsPath);
36
- const spector = entries.find((e) => e.endsWith(".spector"));
37
- if (!spector) throw new Error(`No .spector workspace file found in directory: ${wsPath}`);
38
- return path.join(wsPath, spector);
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
- async function loadWorkspace(wsPath) {
41
- const resolved = await resolveWorkspacePath(wsPath);
42
- const raw = await promises.readFile(resolved, "utf8");
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
- async function loadCollections(ws, dir, filterName) {
46
- const cols = [];
47
- for (const relPath of ws.collections) {
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
- const raw = await promises.readFile(path.join(dir, relPath), "utf8");
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 cols;
92
+ return out;
56
93
  }
57
- async function loadEnvironments(ws, dir) {
58
- const envs = [];
59
- for (const relPath of ws.environments) {
60
- try {
61
- envs.push(JSON.parse(await promises.readFile(path.join(dir, relPath), "utf8")));
62
- } catch {
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 envs;
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
- if (mode !== "consumer" && !specUrl && !specPath) {
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 --workspace <path>
192
- api-spector contract run --workspace <path> --mode <consumer|provider|bidirectional> [options]
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
  }