@testsmith/api-spector 0.4.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,10 @@ const promises = require("fs/promises");
3
3
  const path = require("path");
4
4
  const crypto = require("crypto");
5
5
  const undici = require("undici");
6
- const requestExec = require("./request-exec-BH-M3KqZ.js");
6
+ const requestExec = require("./request-exec-D_xyyt3_.js");
7
7
  const Ajv = require("ajv");
8
8
  const jsYaml = require("js-yaml");
9
+ require("http");
9
10
  const MATCH_KEY = "__match";
10
11
  function isMatcher$1(node) {
11
12
  return typeof node === "object" && node !== null && !Array.isArray(node) && typeof node[MATCH_KEY] === "string";
@@ -706,9 +707,23 @@ function validateBody(schema, bodyText) {
706
707
  }
707
708
  return violations;
708
709
  }
709
- async function executeContract(req, vars) {
710
- const url = requestExec.buildUrl(req.url, req.params, vars);
710
+ async function executeContract(req, vars, providerBaseUrl) {
711
+ const url = requestExec.rebaseUrl(requestExec.buildUrl(req.url, req.params, vars), providerBaseUrl);
711
712
  const start = Date.now();
713
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
714
+ return {
715
+ requestId: req.id,
716
+ requestName: req.name,
717
+ method: req.method,
718
+ url,
719
+ passed: false,
720
+ violations: [{
721
+ type: "status_mismatch",
722
+ message: `Request URL "${url}" has no host. Set a base URL to send it against, or set a {{baseUrl}} variable in the active environment.`
723
+ }],
724
+ durationMs: Date.now() - start
725
+ };
726
+ }
712
727
  try {
713
728
  const headers = new undici.Headers();
714
729
  for (const h of req.headers) {
@@ -759,11 +774,11 @@ async function executeContract(req, vars) {
759
774
  };
760
775
  }
761
776
  }
762
- async function runConsumerContracts(requests, envVars, collectionVars = {}) {
777
+ async function runConsumerContracts(requests, envVars, collectionVars = {}, providerBaseUrl) {
763
778
  const vars = { ...envVars, ...collectionVars };
764
779
  const contractRequests = requests.filter((r) => !r.disabled && hasContract(r.contract));
765
780
  const start = Date.now();
766
- const results = await Promise.all(contractRequests.map((r) => executeContract(r, vars)));
781
+ const results = await Promise.all(contractRequests.map((r) => executeContract(r, vars, providerBaseUrl)));
767
782
  const passed = results.filter((r) => r.passed).length;
768
783
  return {
769
784
  mode: "consumer",
@@ -937,19 +952,6 @@ async function runProviderVerification(requests, envVars, collectionVars = {}, s
937
952
  durationMs: Date.now() - start
938
953
  };
939
954
  }
940
- function rebaseUrl(fullUrl, providerBaseUrl) {
941
- if (!providerBaseUrl) return fullUrl;
942
- try {
943
- const orig = new URL(fullUrl, "http://placeholder.invalid");
944
- const base = new URL(providerBaseUrl);
945
- const basePath = base.pathname.replace(/\/$/, "");
946
- base.pathname = (basePath + orig.pathname).replace(/\/{2,}/g, "/");
947
- base.search = orig.search;
948
- return base.toString();
949
- } catch {
950
- return fullUrl;
951
- }
952
- }
953
955
  async function setupState(stateHandlerUrl, state, action) {
954
956
  if (!stateHandlerUrl) {
955
957
  return {
@@ -982,7 +984,7 @@ async function setupState(stateHandlerUrl, state, action) {
982
984
  }
983
985
  }
984
986
  async function verifyInteraction(req, vars, providerBaseUrl, stateHandlerUrl) {
985
- const url = rebaseUrl(requestExec.buildUrl(req.url, req.params, vars), providerBaseUrl);
987
+ const url = requestExec.rebaseUrl(requestExec.buildUrl(req.url, req.params, vars), providerBaseUrl);
986
988
  const start = Date.now();
987
989
  const violations = [];
988
990
  const states = req.contract?.providerStates ?? [];
@@ -1788,7 +1790,7 @@ async function runFuzz(opts) {
1788
1790
  params: c.params,
1789
1791
  body: c.bodyJson !== void 0 ? { mode: "json", json: c.bodyJson } : req.body
1790
1792
  };
1791
- const resolvedUrl = rebaseUrl(requestExec.buildUrl(fuzzReq.url, fuzzReq.params, vars), opts.providerBaseUrl);
1793
+ const resolvedUrl = requestExec.rebaseUrl(requestExec.buildUrl(fuzzReq.url, fuzzReq.params, vars), opts.providerBaseUrl);
1792
1794
  let sentSnapshot = { method: req.method, url: resolvedUrl, headers: {} };
1793
1795
  try {
1794
1796
  const ex = await requestExec.performHttpExchange({
@@ -1846,7 +1848,7 @@ async function runFuzz(opts) {
1846
1848
  requestId: req.id,
1847
1849
  requestName: req.name,
1848
1850
  method: req.method,
1849
- url: rebaseUrl(requestExec.buildUrl(req.url, req.params, vars), opts.providerBaseUrl),
1851
+ url: requestExec.rebaseUrl(requestExec.buildUrl(req.url, req.params, vars), opts.providerBaseUrl),
1850
1852
  cases: cases.length,
1851
1853
  findings,
1852
1854
  trace
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- const handle = require("./handle-rimXXdJH.js");
2
+ const handle = require("./handle-b6bp3fb0.js");
3
3
  const https = require("https");
4
4
  const http = require("http");
5
5
  const xmldom = require("@xmldom/xmldom");
@@ -315,7 +315,7 @@ function registerSoapHandlers(ipc) {
315
315
  handle.handleIpc(ipc, handle.IPC.wsdl.import, async (_event, opts) => {
316
316
  const { validateWsdlImport } = await Promise.resolve().then(() => require("./ipc-validate-k6KI8adf.js"));
317
317
  validateWsdlImport(opts);
318
- const { importWsdl } = await Promise.resolve().then(() => require("./import-DcenB5Q_.js"));
318
+ const { importWsdl } = await Promise.resolve().then(() => require("./import-BeY7gjWL.js"));
319
319
  const wsdlText = opts.xml ?? (opts.url ? await fetchUrl(opts.url) : "");
320
320
  if (!wsdlText) throw new Error("wsdl:import requires either `url` or `xml`");
321
321
  return importWsdl(wsdlText, { name: opts.name, existingMockPorts: opts.existingMockPorts });
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const promises = require("fs/promises");
4
+ const jsYaml = require("js-yaml");
5
+ const undici = require("undici");
6
+ const coverage = require("./chunks/coverage-C54mZEh_.js");
7
+ const cliCommon = require("./chunks/cli-common-BKYgsbGB.js");
8
+ require("path");
9
+ require("crypto");
10
+ const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
11
+ function resolveRef(spec, ref) {
12
+ const parts = ref.replace(/^#\//, "").split("/");
13
+ return parts.reduce((o, k) => o?.[decodeURIComponent(k.replace(/~1/g, "/").replace(/~0/g, "~"))], spec);
14
+ }
15
+ function deref(spec, node, depth = 0, seen = /* @__PURE__ */ new Set()) {
16
+ if (!node || typeof node !== "object" || depth > 8) return node;
17
+ if (Array.isArray(node)) return node.map((n) => deref(spec, n, depth + 1, seen));
18
+ if ("$ref" in node) {
19
+ const target = resolveRef(spec, node.$ref);
20
+ if (!target || seen.has(target)) return {};
21
+ return deref(spec, target, depth + 1, /* @__PURE__ */ new Set([...seen, target]));
22
+ }
23
+ const out = {};
24
+ for (const [k, v] of Object.entries(node)) out[k] = deref(spec, v, depth + 1, seen);
25
+ return out;
26
+ }
27
+ function walk(schema, prefix, depth, types, required) {
28
+ if (!schema || typeof schema !== "object" || depth > 8) return;
29
+ const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
30
+ if (type === "object" || schema.properties) {
31
+ const req = schema.required ?? [];
32
+ for (const [name, sub] of Object.entries(schema.properties ?? {})) {
33
+ const p = prefix ? `${prefix}.${name}` : name;
34
+ const subType = (Array.isArray(sub?.type) ? sub.type[0] : sub?.type) ?? (sub?.properties ? "object" : "any");
35
+ types.set(p, subType);
36
+ if (req.includes(name)) required.add(p);
37
+ walk(sub, p, depth + 1, types, required);
38
+ }
39
+ } else if (type === "array" && schema.items) {
40
+ walk(schema.items, `${prefix}[]`, depth + 1, types, required);
41
+ }
42
+ }
43
+ function schemaInfo(spec, schema) {
44
+ const types = /* @__PURE__ */ new Map();
45
+ const required = /* @__PURE__ */ new Set();
46
+ if (schema) walk(deref(spec, schema), "", 0, types, required);
47
+ return { types, required };
48
+ }
49
+ function requestSchema(spec, op) {
50
+ return deref(spec, op?.requestBody)?.content?.["application/json"]?.schema;
51
+ }
52
+ function successResponseSchema(spec, op) {
53
+ const responses = op?.responses ?? {};
54
+ const code = Object.keys(responses).filter((c) => /^2\d\d$/.test(c)).sort()[0];
55
+ return code ? deref(spec, responses[code])?.content?.["application/json"]?.schema : void 0;
56
+ }
57
+ function paramRequired(spec, pathItem, op) {
58
+ const raw = [...pathItem?.parameters ?? [], ...op?.parameters ?? []].map((p) => deref(spec, p));
59
+ const m = /* @__PURE__ */ new Map();
60
+ for (const p of raw) if (p?.name && p?.in) m.set(`${p.in}:${p.name}`, !!p.required);
61
+ return m;
62
+ }
63
+ function operations(spec) {
64
+ const map = /* @__PURE__ */ new Map();
65
+ for (const [path, item] of Object.entries(spec?.paths ?? {})) {
66
+ if (!item || typeof item !== "object") continue;
67
+ for (const [method, op] of Object.entries(item)) {
68
+ if (!HTTP_METHODS.includes(method.toLowerCase())) continue;
69
+ map.set(`${method.toUpperCase()} ${path}`, { method: method.toUpperCase(), path, pathItem: item, op });
70
+ }
71
+ }
72
+ return map;
73
+ }
74
+ function diffSpecs(oldSpec, newSpec) {
75
+ const oldOps = operations(oldSpec);
76
+ const newOps = operations(newSpec);
77
+ const changes = [];
78
+ for (const [key, o] of oldOps) {
79
+ if (!newOps.has(key)) {
80
+ changes.push({ kind: "operation-removed", breaking: true, method: o.method, path: o.path, detail: `Operation ${key} was removed` });
81
+ }
82
+ }
83
+ for (const [key, n] of newOps) {
84
+ if (!oldOps.has(key)) {
85
+ changes.push({ kind: "operation-added", breaking: false, method: n.method, path: n.path, detail: `Operation ${key} was added` });
86
+ continue;
87
+ }
88
+ const o = oldOps.get(key);
89
+ const label = `${n.method} ${n.path}`;
90
+ const oReq = schemaInfo(oldSpec, requestSchema(oldSpec, o.op));
91
+ const nReq = schemaInfo(newSpec, requestSchema(newSpec, n.op));
92
+ for (const p of nReq.required) {
93
+ if (!oReq.required.has(p)) {
94
+ changes.push({ kind: "request-required-added", breaking: true, method: n.method, path: n.path, detail: `${label}: request field "${p}" is now required` });
95
+ }
96
+ }
97
+ for (const [p, t] of nReq.types) {
98
+ const ot = oReq.types.get(p);
99
+ if (ot && ot !== t) {
100
+ changes.push({ kind: "request-type-changed", breaking: true, method: n.method, path: n.path, detail: `${label}: request field "${p}" type ${ot} -> ${t}` });
101
+ }
102
+ }
103
+ const oRes = schemaInfo(oldSpec, successResponseSchema(oldSpec, o.op));
104
+ const nRes = schemaInfo(newSpec, successResponseSchema(newSpec, n.op));
105
+ for (const [p, t] of oRes.types) {
106
+ if (!nRes.types.has(p)) {
107
+ changes.push({ kind: "response-removed", breaking: true, method: n.method, path: n.path, detail: `${label}: response field "${p}" was removed` });
108
+ } else if (nRes.types.get(p) !== t) {
109
+ changes.push({ kind: "response-type-changed", breaking: true, method: n.method, path: n.path, detail: `${label}: response field "${p}" type ${t} -> ${nRes.types.get(p)}` });
110
+ }
111
+ }
112
+ const oParams = paramRequired(oldSpec, o.pathItem, o.op);
113
+ const nParams = paramRequired(newSpec, n.pathItem, n.op);
114
+ for (const [k, req] of nParams) {
115
+ if (req && !oParams.get(k)) {
116
+ changes.push({ kind: "param-required-added", breaking: true, method: n.method, path: n.path, detail: `${label}: parameter "${k}" is now required` });
117
+ }
118
+ }
119
+ const oCodes = Object.keys(o.op?.responses ?? {}).filter((c) => /^2\d\d$/.test(c));
120
+ const nCodes = new Set(Object.keys(n.op?.responses ?? {}));
121
+ for (const c of oCodes) {
122
+ if (!nCodes.has(c)) {
123
+ changes.push({ kind: "success-code-removed", breaking: true, method: n.method, path: n.path, detail: `${label}: success response ${c} was removed` });
124
+ }
125
+ }
126
+ }
127
+ return changes;
128
+ }
129
+ function summarizeDiff(changes) {
130
+ return {
131
+ breaking: changes.filter((c) => c.breaking).length,
132
+ nonBreaking: changes.filter((c) => !c.breaking).length
133
+ };
134
+ }
135
+ async function loadSpec(source) {
136
+ const isUrl = /^https?:\/\//i.test(source);
137
+ const raw = isUrl ? await (async () => {
138
+ const r = await undici.fetch(source);
139
+ if (!r.ok) throw new Error(`HTTP ${r.status} fetching ${source}`);
140
+ return r.text();
141
+ })() : await promises.readFile(source, "utf8");
142
+ return /\.ya?ml$/i.test(source) || !isUrl && !raw.trim().startsWith("{") ? jsYaml.load(raw) : JSON.parse(raw);
143
+ }
144
+ async function analyseImpact(changes, wsPath) {
145
+ const { workspace, dir } = await cliCommon.loadWorkspace(wsPath);
146
+ const collections = await cliCommon.loadCollections(workspace, dir);
147
+ const reqs = [];
148
+ for (const col of collections) {
149
+ for (const r of Object.values(col.requests ?? {})) {
150
+ if (r.disabled) continue;
151
+ reqs.push({ name: `${col.name} / ${r.name}`, method: r.method, url: r.url });
152
+ }
153
+ }
154
+ return changes.filter((c) => c.breaking && c.method).map((change) => ({
155
+ change,
156
+ tests: reqs.filter((r) => r.method.toUpperCase() === change.method && coverage.pathMatches(r.url, change.path)).map((r) => r.name)
157
+ }));
158
+ }
159
+ async function main() {
160
+ const argv = process.argv.slice(2);
161
+ const positional = argv.filter((a) => !a.startsWith("--"));
162
+ const args = cliCommon.parseArgs(argv);
163
+ if (args.help || args.h || positional.length < 2) {
164
+ console.log("\nUsage:\n api-spector compare <old-spec> <new-spec> [--workspace <path>] [--fail-on-breaking] [--json]\n");
165
+ process.exit(positional.length < 2 && !args.help && !args.h ? 2 : 0);
166
+ }
167
+ const [oldSource, newSource] = positional;
168
+ let changes;
169
+ try {
170
+ const [oldSpec, newSpec] = await Promise.all([loadSpec(oldSource), loadSpec(newSource)]);
171
+ changes = diffSpecs(oldSpec, newSpec);
172
+ } catch (err) {
173
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
174
+ process.exit(2);
175
+ }
176
+ const impact = args.workspace ? await analyseImpact(changes, args.workspace) : null;
177
+ const { breaking, nonBreaking } = summarizeDiff(changes);
178
+ if (args.json) {
179
+ console.log(JSON.stringify({ changes, impact, summary: { breaking, nonBreaking } }, null, 2));
180
+ } else {
181
+ console.log("");
182
+ if (breaking) {
183
+ console.log(cliCommon.color(" BREAKING CHANGES", cliCommon.C.bold, cliCommon.C.red));
184
+ for (const c of changes.filter((c2) => c2.breaking)) console.log(` ${cliCommon.color("✗", cliCommon.C.red)} ${c.detail}`);
185
+ console.log("");
186
+ }
187
+ if (nonBreaking) {
188
+ console.log(cliCommon.color(" NON-BREAKING", cliCommon.C.bold, cliCommon.C.green));
189
+ for (const c of changes.filter((c2) => !c2.breaking)) console.log(` ${cliCommon.color("✓", cliCommon.C.green)} ${c.detail}`);
190
+ console.log("");
191
+ }
192
+ if (!changes.length) console.log(cliCommon.color(" No differences.\n", cliCommon.C.gray));
193
+ if (impact) {
194
+ const hit = impact.filter((a) => a.tests.length > 0);
195
+ const totalTests = new Set(hit.flatMap((a) => a.tests)).size;
196
+ console.log(cliCommon.color(" IMPACT", cliCommon.C.bold, cliCommon.C.white));
197
+ if (!breaking) {
198
+ console.log(cliCommon.color(" No breaking changes. Safe to deploy.\n", cliCommon.C.green));
199
+ } else if (totalTests === 0) {
200
+ console.log(` ${breaking} breaking change(s), but no test in this workspace exercises the affected operations.`);
201
+ console.log(cliCommon.color(" Recommendation: add tests for those operations, then re-check.\n", cliCommon.C.yellow));
202
+ } else {
203
+ for (const a of hit) {
204
+ console.log(` ${cliCommon.color(a.change.detail, cliCommon.C.yellow)}`);
205
+ for (const t of a.tests) console.log(cliCommon.color(` - ${t}`, cliCommon.C.gray));
206
+ }
207
+ console.log("");
208
+ console.log(cliCommon.color(` BLOCK DEPLOYMENT: ${breaking} breaking change(s) affect ${totalTests} test(s).`, cliCommon.C.bold, cliCommon.C.red));
209
+ console.log("");
210
+ }
211
+ }
212
+ }
213
+ if (args["fail-on-breaking"] && breaking > 0) process.exit(1);
214
+ }
215
+ main().catch((err) => {
216
+ console.error(err instanceof Error ? err.message : String(err));
217
+ process.exit(2);
218
+ });
@@ -1,30 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
- var __create = Object.create;
4
- var __defProp = Object.defineProperty;
5
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
- var __getOwnPropNames = Object.getOwnPropertyNames;
7
- var __getProtoOf = Object.getPrototypeOf;
8
- var __hasOwnProp = Object.prototype.hasOwnProperty;
9
- var __copyProps = (to, from, except, desc) => {
10
- if (from && typeof from === "object" || typeof from === "function") {
11
- for (let key of __getOwnPropNames(from))
12
- if (!__hasOwnProp.call(to, key) && key !== except)
13
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
- }
15
- return to;
16
- };
17
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
- // If the importer is in node compatibility mode or this is not an ESM
19
- // file that has been converted to a CommonJS file using a Babel-
20
- // compatible transform (i.e. "__esModule" has not been set), then set
21
- // "default" to the CommonJS "module.exports" for node compatibility.
22
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
- mod
24
- ));
25
3
  const promises = require("fs/promises");
26
4
  const path = require("path");
27
- const snapshots = require("./chunks/snapshots-CtYxkSLz.js");
5
+ const snapshots = require("./chunks/snapshots-BJiZNuRN.js");
28
6
  const crypto = require("crypto");
29
7
  const undici = require("undici");
30
8
  const os = require("os");
@@ -32,9 +10,13 @@ const cliCommon = require("./chunks/cli-common-BKYgsbGB.js");
32
10
  const child_process = require("child_process");
33
11
  const jsYaml = require("js-yaml");
34
12
  const environments = require("./chunks/environments-iM3SUM-4.js");
35
- require("./chunks/request-exec-BH-M3KqZ.js");
13
+ require("./chunks/request-exec-D_xyyt3_.js");
36
14
  require("tls");
37
- require("./chunks/handle-rimXXdJH.js");
15
+ require("./chunks/handle-b6bp3fb0.js");
16
+ require("node:fs");
17
+ require("node:os");
18
+ require("node:path");
19
+ require("node:crypto");
38
20
  require("dayjs");
39
21
  require("vm");
40
22
  require("tv4");
@@ -151,71 +133,6 @@ async function fireWebhooks(hooks, payload, env = process.env, log = console.log
151
133
  }
152
134
  }));
153
135
  }
154
- function snapshotState(results, envs) {
155
- const state = { results: {}, deployments: {} };
156
- for (const r of results) state.results[`${r.pacticipant}@@${r.version}`] = r.recordedAt;
157
- for (const e of envs) {
158
- for (const [p, d] of Object.entries(e.deployed)) {
159
- state.deployments[`${e.name}@@${p}`] = `${d.version}@@${d.recordedAt}`;
160
- }
161
- }
162
- return state;
163
- }
164
- function diffState(prev, next, results) {
165
- const events = [];
166
- for (const [key, recordedAt] of Object.entries(next.results)) {
167
- if (prev.results[key] === recordedAt) continue;
168
- const [pacticipant, version] = key.split("@@");
169
- const rec = results.find((r) => r.pacticipant === pacticipant && r.version === version);
170
- events.push({
171
- event: "result-recorded",
172
- pacticipant,
173
- version,
174
- passed: rec?.passed,
175
- recordedAt
176
- });
177
- }
178
- for (const [key, value] of Object.entries(next.deployments)) {
179
- if (prev.deployments[key] === value) continue;
180
- const [environment, pacticipant] = key.split("@@");
181
- const [version, recordedAt] = value.split("@@");
182
- events.push({
183
- event: "deployment-recorded",
184
- pacticipant,
185
- version,
186
- environment,
187
- recordedAt
188
- });
189
- }
190
- return events;
191
- }
192
- function watchContractEvents(dir, hooks, intervalMs = 1e4, log = console.log) {
193
- let prev = null;
194
- let running = false;
195
- const tick = async () => {
196
- if (running) return;
197
- running = true;
198
- try {
199
- const [results, envs] = await Promise.all([snapshots.listResults(dir), snapshots.listEnvironments(dir)]);
200
- const next = snapshotState(results, envs);
201
- if (prev !== null) {
202
- for (const payload of diffState(prev, next, results)) {
203
- await fireWebhooks(hooks, payload, process.env, log);
204
- }
205
- }
206
- prev = next;
207
- } catch (e) {
208
- log(` [webhook] scan failed: ${e instanceof Error ? e.message : String(e)}`);
209
- } finally {
210
- running = false;
211
- }
212
- };
213
- void tick();
214
- const timer = setInterval(() => {
215
- void tick();
216
- }, intervalMs);
217
- return () => clearInterval(timer);
218
- }
219
136
  function brokerConfigFromEnv() {
220
137
  return {
221
138
  endpoint: (process.env["API_SPECTOR_CLOUD_ENDPOINT"] || "https://api-spector.dev").replace(/\/+$/, ""),
@@ -288,6 +205,30 @@ async function recordDeployment(cfg, input) {
288
205
  const r = await brokerFetch(cfg, path2, "POST", {});
289
206
  if (!r.ok) fail(r, "record-deployment failed");
290
207
  }
208
+ async function checkCompatibility(cfg, input) {
209
+ const q = new URLSearchParams({ consumer: input.consumer, consumerVersion: input.consumerVersion, provider: input.provider, providerVersion: input.providerVersion }).toString();
210
+ const r = await brokerFetch(cfg, "/api/compatibility?" + q, "GET");
211
+ if (r.status !== 200 && r.status !== 409) fail(r, "Compatibility check failed");
212
+ return { compatible: r.json?.compatible === true, checks: r.json?.checks ?? [] };
213
+ }
214
+ async function fetchContracts(cfg, input = {}) {
215
+ const q = new URLSearchParams();
216
+ if (input.consumer) q.set("consumer", input.consumer);
217
+ if (input.provider) q.set("provider", input.provider);
218
+ const suffix = q.toString() ? "?" + q.toString() : "";
219
+ const r = await brokerFetch(cfg, "/api/contracts" + suffix, "GET");
220
+ if (!r.ok) fail(r, "Fetch contracts failed");
221
+ return r.json?.contracts ?? [];
222
+ }
223
+ async function publishVerification(cfg, input) {
224
+ const r = await brokerFetch(cfg, "/api/verifications", "POST", {
225
+ contractId: input.contractId,
226
+ providerVersion: input.providerVersion,
227
+ success: input.success,
228
+ ...input.buildUrl ? { buildUrl: input.buildUrl } : {}
229
+ });
230
+ if (!r.ok) fail(r, "Publish verification failed");
231
+ }
291
232
  function resolveVersion(override) {
292
233
  if (override) return override;
293
234
  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"];
@@ -467,7 +408,7 @@ async function cmdRun(args) {
467
408
  const modeValue = mode;
468
409
  switch (modeValue) {
469
410
  case "consumer":
470
- report = await snapshots.runConsumerContracts(contractRequests, envVars, collectionVars);
411
+ report = await snapshots.runConsumerContracts(contractRequests, envVars, collectionVars, providerBaseUrl);
471
412
  break;
472
413
  case "provider":
473
414
  report = await snapshots.runProviderVerification(allRequests, envVars, collectionVars, specUrl, specPath, requestBaseUrl);
@@ -740,8 +681,8 @@ async function cmdWebhooks(args) {
740
681
  console.log(" ]");
741
682
  console.log(" }");
742
683
  console.log("");
743
- console.log(" $NAME tokens are replaced from the serving process environment.");
744
- console.log(" The dashboard (`contract report --serve`) fires them when new results appear.");
684
+ console.log(" $NAME tokens are replaced from the process environment.");
685
+ console.log(" Run `contract webhooks --test` to send a sample event to each configured URL.");
745
686
  return;
746
687
  }
747
688
  console.log("");
@@ -769,60 +710,6 @@ async function cmdReport(args) {
769
710
  process.exit(2);
770
711
  }
771
712
  const { dir } = await cliCommon.loadWorkspace(wsArg);
772
- if (args["serve"]) {
773
- const port = typeof args["port"] === "string" ? Number(args["port"]) : 8080;
774
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
775
- console.error(" [error] --port must be a number between 1 and 65535");
776
- process.exit(2);
777
- }
778
- const { createServer } = await import("http");
779
- const server = createServer(async (req, res) => {
780
- try {
781
- const url = req.url ?? "/";
782
- if (url === "/healthz") {
783
- res.writeHead(200, { "Content-Type": "text/plain" });
784
- res.end("ok");
785
- return;
786
- }
787
- const records2 = await snapshots.listResults(dir);
788
- const runMatch = /^\/run\/([^/]+)\/([^/]+)$/.exec(url);
789
- if (runMatch) {
790
- const pacticipant = decodeURIComponent(runMatch[1]);
791
- const version = decodeURIComponent(runMatch[2]);
792
- const rec = records2.find((r) => r.pacticipant === pacticipant && r.version === version);
793
- if (!rec) {
794
- res.writeHead(404, { "Content-Type": "text/plain" });
795
- res.end("No recorded result for that pacticipant/version");
796
- return;
797
- }
798
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
799
- res.end(snapshots.reportToHtml(rec.report, {
800
- title: `${pacticipant} @ ${version}`,
801
- generatedAt: rec.recordedAt
802
- }));
803
- return;
804
- }
805
- const environments22 = await snapshots.listEnvironments(dir);
806
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
807
- res.end(snapshots.dashboardToHtml(records2, (/* @__PURE__ */ new Date()).toISOString(), { runLinkBase: "/run", environments: environments22 }));
808
- } catch (e) {
809
- res.writeHead(500, { "Content-Type": "text/plain" });
810
- res.end(e instanceof Error ? e.message : String(e));
811
- }
812
- });
813
- server.listen(port, () => {
814
- console.log(` Contract dashboard serving at http://localhost:${port}`);
815
- console.log(` Workspace: ${wsArg} (results re-read on every request)`);
816
- console.log(" Read-only: record new results via `contract run --record`, then refresh.");
817
- });
818
- const hooks = await loadWebhookConfig(dir);
819
- if (hooks.length > 0) {
820
- const intervalMs = typeof args["webhook-interval"] === "string" ? Math.max(2, Number(args["webhook-interval"])) * 1e3 : 1e4;
821
- watchContractEvents(dir, hooks, intervalMs);
822
- console.log(` Webhooks: ${hooks.length} configured (polling every ${intervalMs / 1e3}s)`);
823
- }
824
- return;
825
- }
826
713
  const out = typeof args["html"] === "string" ? args["html"] : "contract-dashboard.html";
827
714
  const records = await snapshots.listResults(dir);
828
715
  const environments2 = await snapshots.listEnvironments(dir);
@@ -981,6 +868,40 @@ async function cmdCloudRecordDeployment(args) {
981
868
  await recordDeployment(brokerConfigFromEnv(), { pacticipant, version, environment });
982
869
  console.log(` Recorded ${pacticipant}@${version.slice(0, 7)} deployed to ${environment}`);
983
870
  }
871
+ async function cmdCheck(args) {
872
+ const consumer = str(args["consumer"]) ?? die("--consumer <name> is required");
873
+ const consumerVersion = str(args["consumer-version"]) ?? die("--consumer-version <ver> is required");
874
+ const provider = str(args["provider"]) ?? die("--provider <name> is required");
875
+ const providerVersion = str(args["provider-version"]) ?? die("--provider-version <ver> is required");
876
+ const { compatible, checks } = await checkCompatibility(brokerConfigFromEnv(), { consumer, consumerVersion, provider, providerVersion });
877
+ if (compatible) {
878
+ console.log(` ✓ ${consumer}@${consumerVersion} is compatible with ${provider}@${providerVersion}`);
879
+ return;
880
+ }
881
+ console.error(` ✗ ${consumer}@${consumerVersion} is INCOMPATIBLE with ${provider}@${providerVersion}`);
882
+ for (const c of checks.filter((c2) => !c2.passed)) {
883
+ console.error(` ${c.interaction}`);
884
+ for (const m of c.mismatches ?? []) console.error(` ${m.location}: consumer requires ${m.consumer}, provider ${m.provider}`);
885
+ if ((!c.mismatches || !c.mismatches.length) && c.error) console.error(` ${c.error}`);
886
+ }
887
+ process.exit(1);
888
+ }
889
+ async function cmdPublishVerification(args) {
890
+ const consumer = str(args["consumer"]) ?? die("--consumer <name> is required");
891
+ const provider = str(args["provider"]) ?? die("--provider <name> is required");
892
+ const providerVersion = resolveVersion(str(args["provider-version"]) ?? str(args["version"]));
893
+ const successArg = str(args["success"]);
894
+ if (successArg === void 0) die("--success <true|false> is required");
895
+ const success = successArg === "true" || successArg === "1";
896
+ const buildUrl = str(args["build-url"]);
897
+ const consumerVersion = str(args["consumer-version"]);
898
+ const cfg = brokerConfigFromEnv();
899
+ const contracts = await fetchContracts(cfg, { consumer, provider });
900
+ const match = consumerVersion ? contracts.find((c) => c.consumerVersion === consumerVersion) : contracts[contracts.length - 1];
901
+ if (!match) die(`No published contract for ${consumer} -> ${provider}${consumerVersion ? "@" + consumerVersion : ""}.`);
902
+ await publishVerification(cfg, { contractId: match.id, providerVersion, success, buildUrl });
903
+ console.log(` Published verification: ${provider}@${providerVersion.slice(0, 7)} -> ${consumer}@${match.consumerVersion} = ${success ? "passed" : "FAILED"}`);
904
+ }
984
905
  async function main() {
985
906
  const [, , sub, ...rest] = process.argv;
986
907
  const args = cliCommon.parseArgs(rest);
@@ -991,6 +912,8 @@ async function main() {
991
912
  if (sub === "publish-spec") return wantsCloud(args) ? cmdPublishSpec(args) : cmdPublishSpecLocal(args);
992
913
  if (sub === "preview") return cmdPreview(args);
993
914
  if (sub === "deploy-check" || sub === "can-i-deploy") return wantsCloud(args) ? cmdCloudCanIDeploy(args) : cmdCanIDeploy(args);
915
+ if (sub === "check") return cmdCheck(args);
916
+ if (sub === "publish-verification") return cmdPublishVerification(args);
994
917
  if (sub === "record-deployment") return wantsCloud(args) ? cmdCloudRecordDeployment(args) : cmdRecordDeployment(args);
995
918
  if (sub === "environments") return cmdEnvironments(args);
996
919
  if (sub === "webhooks") return cmdWebhooks(args);
@@ -1003,7 +926,7 @@ async function main() {
1003
926
  api-spector contract list --workspace <path>
1004
927
  api-spector contract pin --workspace <path> --spec-url <url> | --spec-path <file> [--name <label>]
1005
928
  api-spector contract run --workspace <path> --mode <consumer|provider|provider-live|bidirectional> [options]
1006
- api-spector contract report --workspace <path> [--html <path>] [--serve [--port <n>]]
929
+ api-spector contract report --workspace <path> [--html <path>]
1007
930
  api-spector contract deploy-check --workspace <path> --pacticipant <name> --app-version <ver> [--to <env>]
1008
931
  api-spector contract record-deployment --workspace <path> --pacticipant <name> --app-version <ver> --env <name>
1009
932
  api-spector contract environments --workspace <path>
@@ -1033,7 +956,8 @@ async function main() {
1033
956
  --collection <name> Filter to one collection
1034
957
  --environment <name> Environment for {{var}} resolution
1035
958
  --request-base-url <url> Strip this host before matching spec paths
1036
- --provider-base-url <url> (provider-live) rebase requests onto this origin
959
+ --provider-base-url <url> (provider-live, consumer) rebase requests onto
960
+ this origin (lets host-less design contracts run)
1037
961
  --states-url <url> (provider-live) provider state handler endpoint
1038
962
  --output <path> Write ContractReport JSON here
1039
963
  --junit <path> Write JUnit XML here (for CI test reporters)