autotel-pact 0.2.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.
Files changed (86) hide show
  1. package/README.md +385 -0
  2. package/dist/audit.cjs +412 -0
  3. package/dist/audit.cjs.map +1 -0
  4. package/dist/audit.d.cts +25 -0
  5. package/dist/audit.d.ts +25 -0
  6. package/dist/audit.js +403 -0
  7. package/dist/audit.js.map +1 -0
  8. package/dist/auto-wrap.cjs +255 -0
  9. package/dist/auto-wrap.cjs.map +1 -0
  10. package/dist/auto-wrap.d.cts +57 -0
  11. package/dist/auto-wrap.d.ts +57 -0
  12. package/dist/auto-wrap.js +248 -0
  13. package/dist/auto-wrap.js.map +1 -0
  14. package/dist/broker.cjs +84 -0
  15. package/dist/broker.cjs.map +1 -0
  16. package/dist/broker.d.cts +23 -0
  17. package/dist/broker.d.ts +23 -0
  18. package/dist/broker.js +80 -0
  19. package/dist/broker.js.map +1 -0
  20. package/dist/cli.cjs +662 -0
  21. package/dist/cli.cjs.map +1 -0
  22. package/dist/cli.d.cts +4 -0
  23. package/dist/cli.d.ts +4 -0
  24. package/dist/cli.js +656 -0
  25. package/dist/cli.js.map +1 -0
  26. package/dist/index.cjs +967 -0
  27. package/dist/index.cjs.map +1 -0
  28. package/dist/index.d.cts +200 -0
  29. package/dist/index.d.ts +200 -0
  30. package/dist/index.js +932 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/ledger-BuBmfWNc.d.ts +22 -0
  33. package/dist/ledger-D88TzN1c.d.cts +22 -0
  34. package/dist/processor.cjs +200 -0
  35. package/dist/processor.cjs.map +1 -0
  36. package/dist/processor.d.cts +58 -0
  37. package/dist/processor.d.ts +58 -0
  38. package/dist/processor.js +193 -0
  39. package/dist/processor.js.map +1 -0
  40. package/dist/provider.cjs +219 -0
  41. package/dist/provider.cjs.map +1 -0
  42. package/dist/provider.d.cts +41 -0
  43. package/dist/provider.d.ts +41 -0
  44. package/dist/provider.js +213 -0
  45. package/dist/provider.js.map +1 -0
  46. package/dist/tag.cjs +50 -0
  47. package/dist/tag.cjs.map +1 -0
  48. package/dist/tag.d.cts +9 -0
  49. package/dist/tag.d.ts +9 -0
  50. package/dist/tag.js +48 -0
  51. package/dist/tag.js.map +1 -0
  52. package/dist/types-BHGiwqcp.d.cts +157 -0
  53. package/dist/types-BHGiwqcp.d.ts +157 -0
  54. package/package.json +108 -0
  55. package/schemas/README.md +24 -0
  56. package/schemas/audit-matrix-v0.2.0.json +78 -0
  57. package/schemas/ledger-entry-v0.2.0.json +77 -0
  58. package/src/attrs.test.ts +35 -0
  59. package/src/attrs.ts +53 -0
  60. package/src/audit.test.ts +189 -0
  61. package/src/audit.ts +251 -0
  62. package/src/auto-wrap.test.ts +149 -0
  63. package/src/auto-wrap.ts +283 -0
  64. package/src/broker.test.ts +175 -0
  65. package/src/broker.ts +118 -0
  66. package/src/cli.test.ts +148 -0
  67. package/src/cli.ts +287 -0
  68. package/src/index.ts +94 -0
  69. package/src/labels.ts +25 -0
  70. package/src/ledger-normalize.test.ts +141 -0
  71. package/src/ledger-normalize.ts +82 -0
  72. package/src/ledger.test.ts +92 -0
  73. package/src/ledger.ts +156 -0
  74. package/src/pact-file.test.ts +124 -0
  75. package/src/pact-file.ts +65 -0
  76. package/src/processor.test.ts +90 -0
  77. package/src/processor.ts +191 -0
  78. package/src/tag.test.ts +72 -0
  79. package/src/tag.ts +21 -0
  80. package/src/types.ts +169 -0
  81. package/src/wrapper-http.test.ts +133 -0
  82. package/src/wrapper-http.ts +194 -0
  83. package/src/wrapper-provider.test.ts +132 -0
  84. package/src/wrapper-provider.ts +163 -0
  85. package/src/wrapper.test.ts +176 -0
  86. package/src/wrapper.ts +221 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Auto-wrap entry for vitest / jest setup files.
3
+ *
4
+ * Importing this module monkey-patches `@pact-foundation/pact`'s
5
+ * `MessageConsumerPact.prototype.verify` and `PactV3.prototype.executeTest`
6
+ * so every contract test in the project records a ledger entry without
7
+ * users having to wrap each call by hand.
8
+ *
9
+ * Usage (vitest):
10
+ *
11
+ * ```ts
12
+ * // vitest.config.ts
13
+ * import { defineConfig } from 'vitest/config';
14
+ * export default defineConfig({
15
+ * test: { setupFiles: ['autotel-pact/auto-wrap'] },
16
+ * });
17
+ * ```
18
+ *
19
+ * Usage (jest):
20
+ *
21
+ * ```js
22
+ * // jest.config.js
23
+ * module.exports = { setupFilesAfterEach: ['autotel-pact/auto-wrap'] };
24
+ * ```
25
+ *
26
+ * Configuration is via environment variables — there's no API surface,
27
+ * because setup files don't get parameters:
28
+ *
29
+ * - `AUTOTEL_PACT_RUN_ID` — tag every ledger entry with this run id
30
+ * - `AUTOTEL_PACT_LEDGER_DIR` — override the default `.autotel-pact/` dir
31
+ *
32
+ * Idempotent: importing twice is a no-op.
33
+ *
34
+ * Safe when Pact-JS isn't installed: logs a single warning and exits
35
+ * cleanly. The same setup file can stay in place for a repo whose
36
+ * non-contract test packages don't have Pact as a dependency.
37
+ */
38
+
39
+ import { span as autotelSpan, getActiveSpan } from 'autotel';
40
+ import { createRequire } from 'node:module';
41
+ import { buildPactAttributes, outcomeAttribute } from './attrs.js';
42
+ import { appendLedgerEntry } from './ledger.js';
43
+ import { LEDGER_ENTRY_SPEC, type InteractionLedgerEntry, type PactInteractionMeta } from './types.js';
44
+ import type { HttpInteraction } from './wrapper-http.js';
45
+ import type { ReifiedMessage } from './wrapper.js';
46
+
47
+ const INSTALLED = Symbol.for('autotel-pact:auto-wrap-installed');
48
+
49
+ interface PactJsModule {
50
+ MessageConsumerPact?: { prototype: Record<string | symbol, unknown> };
51
+ PactV3?: { prototype: Record<string | symbol, unknown> };
52
+ }
53
+
54
+ /**
55
+ * Install the auto-wrap. Called at module load when this file is imported.
56
+ * Exposed for tests + explicit-invocation users.
57
+ *
58
+ * @param pactModule Override Pact-JS resolution. When omitted, requires
59
+ * `@pact-foundation/pact` from disk. Tests can pass synthetic classes.
60
+ *
61
+ * Returns `true` if Pact-JS was found and patched, `false` otherwise.
62
+ */
63
+ export function installAutoWrap(pactModule?: PactJsModule): boolean {
64
+ const pactJs = pactModule ?? loadPactJs();
65
+ if (!pactJs) return false;
66
+
67
+ patchMessagePact(pactJs);
68
+ patchHttpPact(pactJs);
69
+ return true;
70
+ }
71
+
72
+ function loadPactJs(): PactJsModule | undefined {
73
+ try {
74
+ // Use createRequire so we work from both ESM and CJS without
75
+ // pulling Pact-JS into the dynamic import graph.
76
+ const require = createRequire(import.meta.url);
77
+ return require('@pact-foundation/pact') as PactJsModule;
78
+ } catch {
79
+ process.stderr.write(
80
+ 'autotel-pact/auto-wrap: @pact-foundation/pact not installed — skipping.\n',
81
+ );
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ function patchMessagePact(mod: PactJsModule): void {
87
+ const ctor = mod.MessageConsumerPact;
88
+ if (!ctor) return;
89
+ const proto = ctor.prototype;
90
+ if (proto[INSTALLED]) return;
91
+
92
+ const originalVerify = proto.verify as (
93
+ handler: (m: ReifiedMessage) => Promise<unknown>,
94
+ ) => Promise<unknown>;
95
+
96
+ proto.verify = async function patchedVerify(
97
+ this: { config: { consumer: string; provider: string } },
98
+ handler: (m: ReifiedMessage) => Promise<unknown>,
99
+ ): Promise<unknown> {
100
+ const start = process.hrtime.bigint();
101
+ let captured: ReifiedMessage | undefined;
102
+ const consumer = this.config?.consumer ?? '<unknown>';
103
+ const provider = this.config?.provider ?? '<unknown>';
104
+
105
+ return autotelSpan('pact.interaction', async (span) => {
106
+ span.setAttributes({
107
+ 'pact.consumer': consumer,
108
+ 'pact.provider': provider,
109
+ 'pact.kind': 'message',
110
+ });
111
+
112
+ try {
113
+ const result = await originalVerify.call(this, async (reified) => {
114
+ captured = reified;
115
+ const meta: PactInteractionMeta = {
116
+ consumer,
117
+ provider,
118
+ description: reified.description ?? '<unknown>',
119
+ states: (reified.providerStates ?? []).map((s) => s.name),
120
+ kind: 'message',
121
+ };
122
+ span.setAttributes(buildPactAttributes(meta));
123
+ return handler(reified);
124
+ });
125
+ span.setAttributes(outcomeAttribute('passed'));
126
+ writeAutoLedgerEntry({
127
+ consumer,
128
+ provider,
129
+ interaction: captured?.description ?? '<unknown>',
130
+ states: (captured?.providerStates ?? []).map((s) => s.name),
131
+ kind: 'message',
132
+ outcome: 'passed',
133
+ start,
134
+ });
135
+ return result;
136
+ } catch (error) {
137
+ span.setAttributes(outcomeAttribute('failed'));
138
+ writeAutoLedgerEntry({
139
+ consumer,
140
+ provider,
141
+ interaction: captured?.description ?? '<unknown>',
142
+ states: (captured?.providerStates ?? []).map((s) => s.name),
143
+ kind: 'message',
144
+ outcome: 'failed',
145
+ start,
146
+ error: error instanceof Error ? error.message : String(error),
147
+ });
148
+ throw error;
149
+ }
150
+ });
151
+ };
152
+
153
+ proto[INSTALLED] = true;
154
+ }
155
+
156
+ function patchHttpPact(mod: PactJsModule): void {
157
+ const ctor = mod.PactV3;
158
+ if (!ctor) return;
159
+ const proto = ctor.prototype;
160
+ if (proto[INSTALLED]) return;
161
+
162
+ // Track interactions added to each PactV3 instance. Cleared after
163
+ // executeTest emits ledger entries for them, so the same instance can
164
+ // be reused across multiple test cases.
165
+ const tracked = new WeakMap<object, HttpInteraction[]>();
166
+
167
+ const originalAdd = proto.addInteraction as (i: HttpInteraction) => unknown;
168
+ proto.addInteraction = function patchedAddInteraction(this: object, interaction: HttpInteraction) {
169
+ const list = tracked.get(this) ?? [];
170
+ list.push(interaction);
171
+ tracked.set(this, list);
172
+ return originalAdd.call(this, interaction);
173
+ };
174
+
175
+ const originalExecute = proto.executeTest as <T>(
176
+ fn: (server: { url: string; port: number }) => Promise<T>,
177
+ ) => Promise<T | undefined>;
178
+
179
+ proto.executeTest = async function patchedExecuteTest<T>(
180
+ this: { opts?: { consumer?: string; provider?: string } },
181
+ fn: (server: { url: string; port: number }) => Promise<T>,
182
+ ): Promise<T | undefined> {
183
+ const start = process.hrtime.bigint();
184
+ const consumer = this.opts?.consumer ?? '<unknown>';
185
+ const provider = this.opts?.provider ?? '<unknown>';
186
+ const interactions = tracked.get(this) ?? [];
187
+ tracked.set(this, []);
188
+
189
+ return autotelSpan('pact.interaction', async (span) => {
190
+ span.setAttributes({
191
+ 'pact.consumer': consumer,
192
+ 'pact.provider': provider,
193
+ 'pact.kind': 'http',
194
+ });
195
+ // If multiple interactions were added before executeTest, the span
196
+ // attributes describe the first; ledger entries are still written
197
+ // for each. Most real tests use one interaction per executeTest.
198
+ const first = interactions[0];
199
+ if (first) {
200
+ span.setAttributes(
201
+ buildPactAttributes({
202
+ consumer,
203
+ provider,
204
+ description: first.uponReceiving,
205
+ states: (first.states ?? []).map((s) => s.description),
206
+ kind: 'http',
207
+ }),
208
+ );
209
+ }
210
+
211
+ try {
212
+ const result = (await originalExecute.call(this, fn)) as T | undefined;
213
+ span.setAttributes(outcomeAttribute('passed'));
214
+ for (const i of interactions) {
215
+ writeAutoLedgerEntry({
216
+ consumer,
217
+ provider,
218
+ interaction: i.uponReceiving,
219
+ states: (i.states ?? []).map((s) => s.description),
220
+ kind: 'http',
221
+ outcome: 'passed',
222
+ start,
223
+ });
224
+ }
225
+ return result;
226
+ } catch (error_) {
227
+ span.setAttributes(outcomeAttribute('failed'));
228
+ const error = error_ instanceof Error ? error_.message : String(error_);
229
+ for (const i of interactions) {
230
+ writeAutoLedgerEntry({
231
+ consumer,
232
+ provider,
233
+ interaction: i.uponReceiving,
234
+ states: (i.states ?? []).map((s) => s.description),
235
+ kind: 'http',
236
+ outcome: 'failed',
237
+ start,
238
+ error,
239
+ });
240
+ }
241
+ throw error_;
242
+ }
243
+ });
244
+ };
245
+
246
+ proto[INSTALLED] = true;
247
+ }
248
+
249
+ function writeAutoLedgerEntry(args: {
250
+ consumer: string;
251
+ provider: string;
252
+ interaction: string;
253
+ states: string[];
254
+ kind: 'message' | 'http';
255
+ outcome: 'passed' | 'failed';
256
+ start: bigint;
257
+ error?: string;
258
+ }): void {
259
+ const ctx = getActiveSpan()?.spanContext();
260
+ const entry: InteractionLedgerEntry = {
261
+ type: 'interaction',
262
+ spec: LEDGER_ENTRY_SPEC,
263
+ consumer: args.consumer,
264
+ provider: args.provider,
265
+ interaction: args.interaction,
266
+ states: args.states,
267
+ kind: args.kind,
268
+ source: 'test',
269
+ role: 'consumer',
270
+ outcome: args.outcome,
271
+ duration_ms: Number(process.hrtime.bigint() - args.start) / 1e6,
272
+ observed_at: new Date().toISOString(),
273
+ trace_id: ctx?.traceId,
274
+ span_id: ctx?.spanId,
275
+ run_id: process.env.AUTOTEL_PACT_RUN_ID,
276
+ git_sha: process.env.GIT_SHA ?? process.env.GITHUB_SHA,
277
+ error: args.error,
278
+ };
279
+ appendLedgerEntry(entry);
280
+ }
281
+
282
+ // Install on import — this file is meant to be loaded once as a setup file.
283
+ installAutoWrap();
@@ -0,0 +1,175 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ brokerConfigFromEnv,
4
+ fetchBrokerVerifications,
5
+ parseBrokerVerificationResult,
6
+ } from './broker.js';
7
+
8
+ afterEach(() => {
9
+ vi.unstubAllGlobals();
10
+ vi.unstubAllEnvs();
11
+ });
12
+
13
+ describe('parseBrokerVerificationResult', () => {
14
+ it('parses success and verifiedAt', () => {
15
+ const r = parseBrokerVerificationResult('A', 'B', {
16
+ success: true,
17
+ verifiedAt: '2026-01-01T00:00:00Z',
18
+ });
19
+ expect(r).toEqual({
20
+ consumer: 'A',
21
+ provider: 'B',
22
+ success: true,
23
+ verifiedAt: '2026-01-01T00:00:00Z',
24
+ });
25
+ });
26
+
27
+ it('falls back to verified_at (snake_case) and createdAt', () => {
28
+ expect(
29
+ parseBrokerVerificationResult('A', 'B', { success: true, verified_at: '2026-01-02T00:00:00Z' }),
30
+ ).toMatchObject({ verifiedAt: '2026-01-02T00:00:00Z' });
31
+ expect(
32
+ parseBrokerVerificationResult('A', 'B', { success: true, createdAt: '2026-01-03T00:00:00Z' }),
33
+ ).toMatchObject({ verifiedAt: '2026-01-03T00:00:00Z' });
34
+ });
35
+
36
+ it('infers success from result: "success" when success field missing', () => {
37
+ expect(
38
+ parseBrokerVerificationResult('A', 'B', { result: 'success' }),
39
+ ).toMatchObject({ success: true });
40
+ });
41
+
42
+ it('returns null for non-object input', () => {
43
+ expect(parseBrokerVerificationResult('A', 'B', null)).toBeNull();
44
+ expect(parseBrokerVerificationResult('A', 'B', 'string')).toBeNull();
45
+ });
46
+ });
47
+
48
+ describe('fetchBrokerVerifications', () => {
49
+ it('fetches latest verification per pair with bearer auth', async () => {
50
+ const fetchMock = vi.fn().mockResolvedValue({
51
+ ok: true,
52
+ json: async () => ({ success: true, verifiedAt: '2026-06-01T00:00:00Z' }),
53
+ });
54
+ vi.stubGlobal('fetch', fetchMock);
55
+
56
+ const results = await fetchBrokerVerifications(
57
+ { baseUrl: 'https://broker.example', token: 'tok' },
58
+ [{ consumer: 'A', provider: 'B' }],
59
+ );
60
+
61
+ expect(results[0]).toMatchObject({ consumer: 'A', provider: 'B', success: true });
62
+ expect(fetchMock).toHaveBeenCalledWith(
63
+ 'https://broker.example/pacts/provider/B/consumer/A/latest/verification-results',
64
+ expect.objectContaining({
65
+ headers: expect.objectContaining({ Authorization: 'Bearer tok' }),
66
+ }),
67
+ );
68
+ });
69
+
70
+ it('uses basic auth when username + password supplied', async () => {
71
+ const fetchMock = vi.fn().mockResolvedValue({
72
+ ok: true,
73
+ json: async () => ({ success: true }),
74
+ });
75
+ vi.stubGlobal('fetch', fetchMock);
76
+
77
+ await fetchBrokerVerifications(
78
+ { baseUrl: 'https://b.example', username: 'u', password: 'p' },
79
+ [{ consumer: 'A', provider: 'B' }],
80
+ );
81
+
82
+ const expectedAuth = `Basic ${Buffer.from('u:p').toString('base64')}`;
83
+ expect(fetchMock).toHaveBeenCalledWith(
84
+ expect.any(String),
85
+ expect.objectContaining({
86
+ headers: expect.objectContaining({ Authorization: expectedAuth }),
87
+ }),
88
+ );
89
+ });
90
+
91
+ it('records error and success:false on non-2xx response', async () => {
92
+ const fetchMock = vi.fn().mockResolvedValue({
93
+ ok: false,
94
+ status: 404,
95
+ statusText: 'Not Found',
96
+ json: async () => ({}),
97
+ });
98
+ vi.stubGlobal('fetch', fetchMock);
99
+
100
+ const [result] = await fetchBrokerVerifications(
101
+ { baseUrl: 'https://b.example' },
102
+ [{ consumer: 'A', provider: 'B' }],
103
+ );
104
+ expect(result).toMatchObject({
105
+ consumer: 'A',
106
+ provider: 'B',
107
+ success: false,
108
+ error: 'HTTP 404 Not Found',
109
+ });
110
+ });
111
+
112
+ it('records error and success:false on network failure', async () => {
113
+ const fetchMock = vi.fn().mockRejectedValue(new Error('ENOTFOUND broker.example'));
114
+ vi.stubGlobal('fetch', fetchMock);
115
+
116
+ const [result] = await fetchBrokerVerifications(
117
+ { baseUrl: 'https://b.example' },
118
+ [{ consumer: 'A', provider: 'B' }],
119
+ );
120
+ expect(result).toMatchObject({
121
+ consumer: 'A',
122
+ provider: 'B',
123
+ success: false,
124
+ error: 'ENOTFOUND broker.example',
125
+ });
126
+ });
127
+
128
+ it('handles multiple pairs and trims trailing slash on base url', async () => {
129
+ const calls: string[] = [];
130
+ const fetchMock = vi.fn().mockImplementation((url: string) => {
131
+ calls.push(url);
132
+ return Promise.resolve({
133
+ ok: true,
134
+ json: async () => ({ success: true }),
135
+ });
136
+ });
137
+ vi.stubGlobal('fetch', fetchMock);
138
+
139
+ const results = await fetchBrokerVerifications(
140
+ { baseUrl: 'https://b.example/' },
141
+ [
142
+ { consumer: 'A', provider: 'B' },
143
+ { consumer: 'C', provider: 'D' },
144
+ ],
145
+ );
146
+
147
+ expect(results).toHaveLength(2);
148
+ expect(calls[0]).toBe(
149
+ 'https://b.example/pacts/provider/B/consumer/A/latest/verification-results',
150
+ );
151
+ expect(calls[1]).toBe(
152
+ 'https://b.example/pacts/provider/D/consumer/C/latest/verification-results',
153
+ );
154
+ });
155
+ });
156
+
157
+ describe('brokerConfigFromEnv', () => {
158
+ it('returns undefined when PACT_BROKER_BASE_URL is unset', () => {
159
+ vi.stubEnv('PACT_BROKER_BASE_URL', '');
160
+ expect(brokerConfigFromEnv()).toBeUndefined();
161
+ });
162
+
163
+ it('reads baseUrl, token, and basic auth from env', () => {
164
+ vi.stubEnv('PACT_BROKER_BASE_URL', 'https://b.example');
165
+ vi.stubEnv('PACT_BROKER_TOKEN', 'tok');
166
+ vi.stubEnv('PACT_BROKER_USERNAME', 'u');
167
+ vi.stubEnv('PACT_BROKER_PASSWORD', 'p');
168
+ expect(brokerConfigFromEnv()).toEqual({
169
+ baseUrl: 'https://b.example',
170
+ token: 'tok',
171
+ username: 'u',
172
+ password: 'p',
173
+ });
174
+ });
175
+ });
package/src/broker.ts ADDED
@@ -0,0 +1,118 @@
1
+ import type { BrokerVerification } from './types.js';
2
+
3
+ export interface BrokerConfig {
4
+ baseUrl: string;
5
+ token?: string;
6
+ username?: string;
7
+ password?: string;
8
+ }
9
+
10
+ export interface ConsumerProviderPair {
11
+ consumer: string;
12
+ provider: string;
13
+ }
14
+
15
+ function authHeaders(config: BrokerConfig): Record<string, string> {
16
+ const headers: Record<string, string> = {
17
+ Accept: 'application/json',
18
+ };
19
+ if (config.token) {
20
+ headers.Authorization = `Bearer ${config.token}`;
21
+ } else if (config.username && config.password) {
22
+ const encoded = Buffer.from(`${config.username}:${config.password}`).toString('base64');
23
+ headers.Authorization = `Basic ${encoded}`;
24
+ }
25
+ return headers;
26
+ }
27
+
28
+ function trimBaseUrl(url: string): string {
29
+ return url.replace(/\/$/, '');
30
+ }
31
+
32
+ /**
33
+ * Parse Pact Broker latest verification result payload.
34
+ */
35
+ export function parseBrokerVerificationResult(
36
+ consumer: string,
37
+ provider: string,
38
+ json: unknown,
39
+ ): BrokerVerification | null {
40
+ if (!json || typeof json !== 'object') return null;
41
+ const body = json as Record<string, unknown>;
42
+ const success =
43
+ body.success === true ||
44
+ (body.success === undefined && body.result === 'success');
45
+ const verifiedAt =
46
+ typeof body.verifiedAt === 'string'
47
+ ? body.verifiedAt
48
+ : typeof body.verified_at === 'string'
49
+ ? body.verified_at
50
+ : typeof body.createdAt === 'string'
51
+ ? body.createdAt
52
+ : undefined;
53
+
54
+ return {
55
+ consumer,
56
+ provider,
57
+ success: !!success,
58
+ verifiedAt,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Fetch latest verification results for each consumer–provider pair.
64
+ */
65
+ export async function fetchBrokerVerifications(
66
+ config: BrokerConfig,
67
+ pairs: ConsumerProviderPair[],
68
+ ): Promise<BrokerVerification[]> {
69
+ const base = trimBaseUrl(config.baseUrl);
70
+ const headers = authHeaders(config);
71
+ const results: BrokerVerification[] = [];
72
+
73
+ for (const { consumer, provider } of pairs) {
74
+ const url = `${base}/pacts/provider/${encodeURIComponent(provider)}/consumer/${encodeURIComponent(consumer)}/latest/verification-results`;
75
+ try {
76
+ const res = await fetch(url, { headers });
77
+ if (!res.ok) {
78
+ results.push({
79
+ consumer,
80
+ provider,
81
+ success: false,
82
+ error: `HTTP ${res.status} ${res.statusText}`.trim(),
83
+ });
84
+ continue;
85
+ }
86
+ const json: unknown = await res.json();
87
+ const parsed = parseBrokerVerificationResult(consumer, provider, json);
88
+ results.push(
89
+ parsed ?? {
90
+ consumer,
91
+ provider,
92
+ success: false,
93
+ error: 'Unparseable broker response',
94
+ },
95
+ );
96
+ } catch (error) {
97
+ results.push({
98
+ consumer,
99
+ provider,
100
+ success: false,
101
+ error: error instanceof Error ? error.message : String(error),
102
+ });
103
+ }
104
+ }
105
+
106
+ return results;
107
+ }
108
+
109
+ export function brokerConfigFromEnv(): BrokerConfig | undefined {
110
+ const baseUrl = process.env.PACT_BROKER_BASE_URL;
111
+ if (!baseUrl) return undefined;
112
+ return {
113
+ baseUrl,
114
+ token: process.env.PACT_BROKER_TOKEN,
115
+ username: process.env.PACT_BROKER_USERNAME,
116
+ password: process.env.PACT_BROKER_PASSWORD,
117
+ };
118
+ }