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.
- package/README.md +385 -0
- package/dist/audit.cjs +412 -0
- package/dist/audit.cjs.map +1 -0
- package/dist/audit.d.cts +25 -0
- package/dist/audit.d.ts +25 -0
- package/dist/audit.js +403 -0
- package/dist/audit.js.map +1 -0
- package/dist/auto-wrap.cjs +255 -0
- package/dist/auto-wrap.cjs.map +1 -0
- package/dist/auto-wrap.d.cts +57 -0
- package/dist/auto-wrap.d.ts +57 -0
- package/dist/auto-wrap.js +248 -0
- package/dist/auto-wrap.js.map +1 -0
- package/dist/broker.cjs +84 -0
- package/dist/broker.cjs.map +1 -0
- package/dist/broker.d.cts +23 -0
- package/dist/broker.d.ts +23 -0
- package/dist/broker.js +80 -0
- package/dist/broker.js.map +1 -0
- package/dist/cli.cjs +662 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +4 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +656 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +967 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +200 -0
- package/dist/index.d.ts +200 -0
- package/dist/index.js +932 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger-BuBmfWNc.d.ts +22 -0
- package/dist/ledger-D88TzN1c.d.cts +22 -0
- package/dist/processor.cjs +200 -0
- package/dist/processor.cjs.map +1 -0
- package/dist/processor.d.cts +58 -0
- package/dist/processor.d.ts +58 -0
- package/dist/processor.js +193 -0
- package/dist/processor.js.map +1 -0
- package/dist/provider.cjs +219 -0
- package/dist/provider.cjs.map +1 -0
- package/dist/provider.d.cts +41 -0
- package/dist/provider.d.ts +41 -0
- package/dist/provider.js +213 -0
- package/dist/provider.js.map +1 -0
- package/dist/tag.cjs +50 -0
- package/dist/tag.cjs.map +1 -0
- package/dist/tag.d.cts +9 -0
- package/dist/tag.d.ts +9 -0
- package/dist/tag.js +48 -0
- package/dist/tag.js.map +1 -0
- package/dist/types-BHGiwqcp.d.cts +157 -0
- package/dist/types-BHGiwqcp.d.ts +157 -0
- package/package.json +108 -0
- package/schemas/README.md +24 -0
- package/schemas/audit-matrix-v0.2.0.json +78 -0
- package/schemas/ledger-entry-v0.2.0.json +77 -0
- package/src/attrs.test.ts +35 -0
- package/src/attrs.ts +53 -0
- package/src/audit.test.ts +189 -0
- package/src/audit.ts +251 -0
- package/src/auto-wrap.test.ts +149 -0
- package/src/auto-wrap.ts +283 -0
- package/src/broker.test.ts +175 -0
- package/src/broker.ts +118 -0
- package/src/cli.test.ts +148 -0
- package/src/cli.ts +287 -0
- package/src/index.ts +94 -0
- package/src/labels.ts +25 -0
- package/src/ledger-normalize.test.ts +141 -0
- package/src/ledger-normalize.ts +82 -0
- package/src/ledger.test.ts +92 -0
- package/src/ledger.ts +156 -0
- package/src/pact-file.test.ts +124 -0
- package/src/pact-file.ts +65 -0
- package/src/processor.test.ts +90 -0
- package/src/processor.ts +191 -0
- package/src/tag.test.ts +72 -0
- package/src/tag.ts +21 -0
- package/src/types.ts +169 -0
- package/src/wrapper-http.test.ts +133 -0
- package/src/wrapper-http.ts +194 -0
- package/src/wrapper-provider.test.ts +132 -0
- package/src/wrapper-provider.ts +163 -0
- package/src/wrapper.test.ts +176 -0
- package/src/wrapper.ts +221 -0
package/src/processor.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { appendLedgerEntryAsync, flushLedgerWrites, type LedgerOptions } from './ledger.js';
|
|
2
|
+
import { PACT_ATTRS } from './attrs.js';
|
|
3
|
+
import { LEDGER_ENTRY_SPEC, type InteractionLedgerEntry, type PactKind } from './types.js';
|
|
4
|
+
|
|
5
|
+
/** Minimal ReadableSpan shape — avoids hard dependency on sdk-trace-base. */
|
|
6
|
+
export interface ReadableSpanLike {
|
|
7
|
+
attributes: Record<string, unknown>;
|
|
8
|
+
spanContext(): { traceId: string; spanId: string };
|
|
9
|
+
/** OTel SpanStatus. code 2 = ERROR (see @opentelemetry/api SpanStatusCode). */
|
|
10
|
+
status?: { code: number; message?: string };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SpanLike {
|
|
14
|
+
spanContext(): { traceId: string; spanId: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Opaque parent context — matches OTel SpanProcessor without a hard dependency. */
|
|
18
|
+
export type OtelContext = unknown;
|
|
19
|
+
|
|
20
|
+
export interface SpanProcessorLike {
|
|
21
|
+
onStart(span: SpanLike, parentContext: OtelContext): void;
|
|
22
|
+
onEnd(span: ReadableSpanLike): void;
|
|
23
|
+
shutdown(): Promise<void>;
|
|
24
|
+
forceFlush(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PactLedgerProcessorOptions extends LedgerOptions {
|
|
28
|
+
/** Max queued ledger writes before dropping (default 1024). */
|
|
29
|
+
maxQueueSize?: number;
|
|
30
|
+
onDrop?: (reason: 'queue_full') => void;
|
|
31
|
+
onWriteError?: (error: unknown) => void;
|
|
32
|
+
onWarn?: (message: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_MAX_QUEUE = 1024;
|
|
36
|
+
const WARN_INTERVAL_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
type QueueItem = { entry: InteractionLedgerEntry; opts: LedgerOptions };
|
|
39
|
+
|
|
40
|
+
function attrString(attrs: Record<string, unknown>, key: string): string | undefined {
|
|
41
|
+
const v = attrs[key];
|
|
42
|
+
if (typeof v === 'string' && v.length > 0) return v;
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function attrStates(attrs: Record<string, unknown>): string[] {
|
|
47
|
+
const v = attrs[PACT_ATTRS.INTERACTION_STATES];
|
|
48
|
+
if (Array.isArray(v)) {
|
|
49
|
+
return v.filter((s): s is string => typeof s === 'string');
|
|
50
|
+
}
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ledgerEntryFromSpan(span: ReadableSpanLike): InteractionLedgerEntry | null {
|
|
55
|
+
const attrs = span.attributes;
|
|
56
|
+
const consumer = attrString(attrs, PACT_ATTRS.CONSUMER);
|
|
57
|
+
const provider = attrString(attrs, PACT_ATTRS.PROVIDER);
|
|
58
|
+
const description = attrString(attrs, PACT_ATTRS.INTERACTION_DESCRIPTION);
|
|
59
|
+
const interactionId = attrString(attrs, PACT_ATTRS.INTERACTION_ID);
|
|
60
|
+
if (!consumer || !provider || (!description && !interactionId)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const kindRaw = attrString(attrs, PACT_ATTRS.KIND);
|
|
65
|
+
const kind: PactKind = kindRaw === 'http' ? 'http' : 'message';
|
|
66
|
+
const ctx = span.spanContext();
|
|
67
|
+
// SpanStatusCode.ERROR === 2. Treat anything else (UNSET, OK) as passed.
|
|
68
|
+
const errored = span.status?.code === 2;
|
|
69
|
+
|
|
70
|
+
const entry: InteractionLedgerEntry = {
|
|
71
|
+
type: 'interaction',
|
|
72
|
+
spec: LEDGER_ENTRY_SPEC,
|
|
73
|
+
consumer,
|
|
74
|
+
provider,
|
|
75
|
+
interaction: description ?? interactionId!,
|
|
76
|
+
interaction_id: interactionId,
|
|
77
|
+
states: attrStates(attrs),
|
|
78
|
+
kind,
|
|
79
|
+
outcome: errored ? 'failed' : 'passed',
|
|
80
|
+
source: 'production',
|
|
81
|
+
role: 'consumer',
|
|
82
|
+
duration_ms: 0,
|
|
83
|
+
observed_at: new Date().toISOString(),
|
|
84
|
+
trace_id: ctx.traceId,
|
|
85
|
+
span_id: ctx.spanId,
|
|
86
|
+
run_id: process.env.AUTOTEL_PACT_RUN_ID,
|
|
87
|
+
git_sha: process.env.GIT_SHA ?? process.env.GITHUB_SHA,
|
|
88
|
+
};
|
|
89
|
+
if (errored && span.status?.message) {
|
|
90
|
+
entry.error = span.status.message;
|
|
91
|
+
}
|
|
92
|
+
return entry;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Records pact-tagged spans to the JSONL ledger. Bounded queue, drop-on-full, fail-open.
|
|
97
|
+
*/
|
|
98
|
+
export class PactLedgerSpanProcessor implements SpanProcessorLike {
|
|
99
|
+
private readonly opts: PactLedgerProcessorOptions;
|
|
100
|
+
private readonly maxQueue: number;
|
|
101
|
+
private pending: QueueItem[] = [];
|
|
102
|
+
private flushing = false;
|
|
103
|
+
private drops = 0;
|
|
104
|
+
private lastWarnAt = 0;
|
|
105
|
+
|
|
106
|
+
constructor(opts: PactLedgerProcessorOptions = {}) {
|
|
107
|
+
this.opts = opts;
|
|
108
|
+
this.maxQueue = opts.maxQueueSize ?? DEFAULT_MAX_QUEUE;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
onStart(_span: SpanLike, _parentContext: OtelContext): void {
|
|
112
|
+
// no-op
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
onEnd(span: ReadableSpanLike): void {
|
|
116
|
+
try {
|
|
117
|
+
const entry = ledgerEntryFromSpan(span);
|
|
118
|
+
if (!entry) return;
|
|
119
|
+
|
|
120
|
+
if (this.pending.length >= this.maxQueue) {
|
|
121
|
+
// FIFO eviction: drop the oldest queued entry to keep the newest.
|
|
122
|
+
// Recent evidence is more valuable than backlog under sustained pressure.
|
|
123
|
+
this.pending.shift();
|
|
124
|
+
this.drops++;
|
|
125
|
+
this.opts.onDrop?.('queue_full');
|
|
126
|
+
this.maybeWarn(
|
|
127
|
+
`autotel-pact: dropped oldest queued ledger entry (queue full, max ${this.maxQueue}). ` +
|
|
128
|
+
`${this.drops} total drops.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.pending.push({ entry, opts: this.opts });
|
|
133
|
+
queueMicrotask(() => {
|
|
134
|
+
void this.flush();
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
this.opts.onWriteError?.(error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private maybeWarn(message: string): void {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
if (now - this.lastWarnAt < WARN_INTERVAL_MS) return;
|
|
144
|
+
this.lastWarnAt = now;
|
|
145
|
+
if (this.opts.onWarn) {
|
|
146
|
+
this.opts.onWarn(message);
|
|
147
|
+
} else {
|
|
148
|
+
console.warn(message);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async flush(): Promise<void> {
|
|
153
|
+
if (this.flushing) return;
|
|
154
|
+
this.flushing = true;
|
|
155
|
+
try {
|
|
156
|
+
while (this.pending.length > 0) {
|
|
157
|
+
const item = this.pending.shift()!;
|
|
158
|
+
try {
|
|
159
|
+
await appendLedgerEntryAsync(item.entry, item.opts);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.opts.onWriteError?.(error);
|
|
162
|
+
this.maybeWarn(
|
|
163
|
+
`autotel-pact: ledger write failed (fail-open): ${error instanceof Error ? error.message : String(error)}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
this.flushing = false;
|
|
169
|
+
if (this.pending.length > 0) {
|
|
170
|
+
queueMicrotask(() => {
|
|
171
|
+
void this.flush();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async forceFlush(): Promise<void> {
|
|
178
|
+
await this.flush();
|
|
179
|
+
await flushLedgerWrites();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async shutdown(): Promise<void> {
|
|
183
|
+
await this.forceFlush();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function createPactLedgerProcessor(
|
|
188
|
+
opts: PactLedgerProcessorOptions = {},
|
|
189
|
+
): PactLedgerSpanProcessor {
|
|
190
|
+
return new PactLedgerSpanProcessor(opts);
|
|
191
|
+
}
|
package/src/tag.test.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { PACT_ATTRS } from './attrs.js';
|
|
3
|
+
|
|
4
|
+
const getActiveSpanMock = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock('autotel', () => ({
|
|
7
|
+
getActiveSpan: () => getActiveSpanMock(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
let tagPactInteraction: typeof import('./tag.js').tagPactInteraction;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
getActiveSpanMock.mockReset();
|
|
14
|
+
({ tagPactInteraction } = await import('./tag.js'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.resetModules();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('tagPactInteraction', () => {
|
|
22
|
+
it('throws when no active span is present', () => {
|
|
23
|
+
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
24
|
+
getActiveSpanMock.mockReturnValue(undefined);
|
|
25
|
+
expect(() =>
|
|
26
|
+
tagPactInteraction({
|
|
27
|
+
consumer: 'A',
|
|
28
|
+
provider: 'B',
|
|
29
|
+
description: 'evt',
|
|
30
|
+
states: [],
|
|
31
|
+
kind: 'message',
|
|
32
|
+
}),
|
|
33
|
+
).toThrowError(/requires an active span/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('stamps pact.* attributes on the active span', () => {
|
|
37
|
+
const setAttributes = vi.fn();
|
|
38
|
+
const setAttribute = vi.fn();
|
|
39
|
+
getActiveSpanMock.mockReturnValue({ setAttributes, setAttribute });
|
|
40
|
+
|
|
41
|
+
tagPactInteraction({
|
|
42
|
+
consumer: 'OrderShipper',
|
|
43
|
+
provider: 'OrderService',
|
|
44
|
+
description: 'an OrderCreated event',
|
|
45
|
+
states: ['order exists'],
|
|
46
|
+
kind: 'message',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(setAttributes).toHaveBeenCalledTimes(1);
|
|
50
|
+
const attrs = setAttributes.mock.calls[0]![0] as Record<string, unknown>;
|
|
51
|
+
expect(attrs[PACT_ATTRS.CONSUMER]).toBe('OrderShipper');
|
|
52
|
+
expect(attrs[PACT_ATTRS.PROVIDER]).toBe('OrderService');
|
|
53
|
+
expect(attrs[PACT_ATTRS.KIND]).toBe('message');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('sets interaction_id when supplied', () => {
|
|
57
|
+
const setAttributes = vi.fn();
|
|
58
|
+
const setAttribute = vi.fn();
|
|
59
|
+
getActiveSpanMock.mockReturnValue({ setAttributes, setAttribute });
|
|
60
|
+
|
|
61
|
+
tagPactInteraction({
|
|
62
|
+
consumer: 'A',
|
|
63
|
+
provider: 'B',
|
|
64
|
+
description: 'evt',
|
|
65
|
+
states: [],
|
|
66
|
+
kind: 'message',
|
|
67
|
+
interactionId: 'iid-123',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(setAttribute).toHaveBeenCalledWith(PACT_ATTRS.INTERACTION_ID, 'iid-123');
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/tag.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getActiveSpan } from 'autotel';
|
|
2
|
+
import { buildPactAttributes, PACT_ATTRS } from './attrs.js';
|
|
3
|
+
import type { PactInteractionMeta } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stamp `pact.*` attributes on the active span for production observation.
|
|
7
|
+
* Requires an active span (wrap handlers with `trace()` first).
|
|
8
|
+
*/
|
|
9
|
+
export function tagPactInteraction(meta: PactInteractionMeta): void {
|
|
10
|
+
const span = getActiveSpan();
|
|
11
|
+
if (!span) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
'autotel-pact: tagPactInteraction requires an active span. Wrap the handler with trace() first.',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
const attrs = buildPactAttributes(meta);
|
|
17
|
+
span.setAttributes(attrs);
|
|
18
|
+
if (meta.interactionId) {
|
|
19
|
+
span.setAttribute(PACT_ATTRS.INTERACTION_ID, meta.interactionId);
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kind of contract interaction observed.
|
|
3
|
+
*/
|
|
4
|
+
export type PactKind = 'message' | 'http';
|
|
5
|
+
|
|
6
|
+
export type PactOutcome = 'passed' | 'failed';
|
|
7
|
+
|
|
8
|
+
export type LedgerSource = 'test' | 'production';
|
|
9
|
+
export type LedgerRole = 'consumer' | 'provider';
|
|
10
|
+
|
|
11
|
+
export const LEDGER_ENTRY_SPEC = 'autotel-pact-ledger-entry/v0.2.0';
|
|
12
|
+
export const AUDIT_MATRIX_SPEC = 'autotel-pact-audit-matrix/v0.2.0';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Metadata about a single Pact interaction, derived from the reified message
|
|
16
|
+
* plus the consumer/provider config. Stamped onto the span and the ledger entry.
|
|
17
|
+
*/
|
|
18
|
+
export interface PactInteractionMeta {
|
|
19
|
+
consumer: string;
|
|
20
|
+
provider: string;
|
|
21
|
+
description: string;
|
|
22
|
+
states: string[];
|
|
23
|
+
kind: PactKind;
|
|
24
|
+
interactionId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Per-interaction ledger evidence (consumer exercise, provider verify, or production tag).
|
|
29
|
+
*/
|
|
30
|
+
export interface InteractionLedgerEntry {
|
|
31
|
+
type?: 'interaction';
|
|
32
|
+
spec: typeof LEDGER_ENTRY_SPEC;
|
|
33
|
+
consumer: string;
|
|
34
|
+
provider: string;
|
|
35
|
+
interaction: string;
|
|
36
|
+
interaction_id?: string;
|
|
37
|
+
states: string[];
|
|
38
|
+
kind: PactKind;
|
|
39
|
+
outcome: PactOutcome;
|
|
40
|
+
source: LedgerSource;
|
|
41
|
+
role: LedgerRole;
|
|
42
|
+
duration_ms: number;
|
|
43
|
+
observed_at: string;
|
|
44
|
+
trace_id?: string;
|
|
45
|
+
span_id?: string;
|
|
46
|
+
run_id?: string;
|
|
47
|
+
git_sha?: string;
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run-level provider verification failure — does not imply per-interaction outcomes.
|
|
53
|
+
*/
|
|
54
|
+
export interface ProviderVerificationRunEntry {
|
|
55
|
+
type: 'provider_verification_run';
|
|
56
|
+
spec: typeof LEDGER_ENTRY_SPEC;
|
|
57
|
+
consumer: string;
|
|
58
|
+
provider: string;
|
|
59
|
+
outcome: 'failed';
|
|
60
|
+
source: LedgerSource;
|
|
61
|
+
role: 'provider';
|
|
62
|
+
observed_at: string;
|
|
63
|
+
error: string;
|
|
64
|
+
run_id?: string;
|
|
65
|
+
git_sha?: string;
|
|
66
|
+
trace_id?: string;
|
|
67
|
+
span_id?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type LedgerRecord = InteractionLedgerEntry | ProviderVerificationRunEntry;
|
|
71
|
+
|
|
72
|
+
export function isInteractionLedgerEntry(
|
|
73
|
+
entry: LedgerRecord,
|
|
74
|
+
): entry is InteractionLedgerEntry {
|
|
75
|
+
return entry.type !== 'provider_verification_run';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isProviderVerificationRun(
|
|
79
|
+
entry: LedgerRecord,
|
|
80
|
+
): entry is ProviderVerificationRunEntry {
|
|
81
|
+
return entry.type === 'provider_verification_run';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Shape of a Pact contract file on disk (subset we read).
|
|
86
|
+
*/
|
|
87
|
+
export interface PactFile {
|
|
88
|
+
consumer: { name: string };
|
|
89
|
+
provider: { name: string };
|
|
90
|
+
messages?: Array<{
|
|
91
|
+
description: string;
|
|
92
|
+
providerStates?: Array<{ name: string }>;
|
|
93
|
+
metadata?: Record<string, unknown>;
|
|
94
|
+
}>;
|
|
95
|
+
interactions?: Array<{
|
|
96
|
+
description: string;
|
|
97
|
+
providerStates?: Array<{ name: string }>;
|
|
98
|
+
metadata?: Record<string, unknown>;
|
|
99
|
+
}>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface BrokerVerification {
|
|
103
|
+
consumer: string;
|
|
104
|
+
provider: string;
|
|
105
|
+
success: boolean;
|
|
106
|
+
verifiedAt?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Populated when the broker could not be reached or returned a non-2xx
|
|
109
|
+
* response. Distinguishes "broker said the pact is not verified" (no error)
|
|
110
|
+
* from "we could not determine verification status" (error set).
|
|
111
|
+
*/
|
|
112
|
+
error?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One row in the audit matrix.
|
|
117
|
+
*/
|
|
118
|
+
export interface AuditRow {
|
|
119
|
+
consumer: string;
|
|
120
|
+
provider: string;
|
|
121
|
+
interaction: string;
|
|
122
|
+
interaction_id?: string;
|
|
123
|
+
kind: PactKind;
|
|
124
|
+
contracted: boolean;
|
|
125
|
+
/** Any interaction-level ledger hit in the window (test or production). */
|
|
126
|
+
observed: boolean;
|
|
127
|
+
test_seen: boolean;
|
|
128
|
+
prod_seen: boolean;
|
|
129
|
+
provider_verified: boolean;
|
|
130
|
+
broker_verified: boolean;
|
|
131
|
+
broker_verified_at?: string;
|
|
132
|
+
/** Set when the broker check failed (network error, non-2xx, parse error). */
|
|
133
|
+
broker_error?: string;
|
|
134
|
+
last_observed_at?: string;
|
|
135
|
+
last_outcome?: PactOutcome;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface AuditMatrix {
|
|
139
|
+
spec: typeof AUDIT_MATRIX_SPEC;
|
|
140
|
+
rows: AuditRow[];
|
|
141
|
+
counts: {
|
|
142
|
+
total: number;
|
|
143
|
+
/** Any contracted row. */
|
|
144
|
+
contracted: number;
|
|
145
|
+
/** Any row with test_seen OR prod_seen. */
|
|
146
|
+
observed: number;
|
|
147
|
+
/** Contracted AND seen in a consumer test. */
|
|
148
|
+
contracted_and_test_seen: number;
|
|
149
|
+
/** Contracted but not seen in a consumer test (stale confidence). */
|
|
150
|
+
contracted_not_test_seen: number;
|
|
151
|
+
/** Seen (test or production) without a matching contract (ungoverned flow). */
|
|
152
|
+
test_or_prod_seen_not_contracted: number;
|
|
153
|
+
test_seen: number;
|
|
154
|
+
prod_seen: number;
|
|
155
|
+
provider_verified: number;
|
|
156
|
+
broker_verified: number;
|
|
157
|
+
};
|
|
158
|
+
window_days: number;
|
|
159
|
+
generated_at: string;
|
|
160
|
+
verification_failures?: ProviderVerificationRunEntry[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface PactInteractionKey {
|
|
164
|
+
consumer: string;
|
|
165
|
+
provider: string;
|
|
166
|
+
interaction: string;
|
|
167
|
+
kind: PactKind;
|
|
168
|
+
interactionId?: string;
|
|
169
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { readLedger } from './ledger.js';
|
|
6
|
+
import {
|
|
7
|
+
withHttpPactInteraction,
|
|
8
|
+
type HttpInteraction,
|
|
9
|
+
type HttpPactLike,
|
|
10
|
+
} from './wrapper-http.js';
|
|
11
|
+
|
|
12
|
+
class FakeHttpPact implements HttpPactLike {
|
|
13
|
+
opts = { consumer: 'Web', provider: 'Catalog' };
|
|
14
|
+
added: HttpInteraction[] = [];
|
|
15
|
+
|
|
16
|
+
constructor(private failTest = false) {}
|
|
17
|
+
|
|
18
|
+
addInteraction(interaction: HttpInteraction): this {
|
|
19
|
+
this.added.push(interaction);
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async executeTest<T>(
|
|
24
|
+
testFn: (s: { url: string; port: number }) => Promise<T>,
|
|
25
|
+
): Promise<T | undefined> {
|
|
26
|
+
if (this.failTest) throw new Error('mock server failed to start');
|
|
27
|
+
return testFn({ url: 'http://127.0.0.1:9999', port: 9999 });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sampleInteraction: HttpInteraction = {
|
|
32
|
+
uponReceiving: 'get all products',
|
|
33
|
+
states: [{ description: 'products exist' }],
|
|
34
|
+
withRequest: { method: 'GET', path: '/products' },
|
|
35
|
+
willRespondWith: { status: 200, body: [] },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let workDir: string;
|
|
39
|
+
let originalCwd: string;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
originalCwd = process.cwd();
|
|
43
|
+
workDir = mkdtempSync(path.join(tmpdir(), 'autotel-pact-http-'));
|
|
44
|
+
process.chdir(workDir);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
process.chdir(originalCwd);
|
|
49
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('withHttpPactInteraction', () => {
|
|
53
|
+
it('adds the interaction to the pact and records a passed entry', async () => {
|
|
54
|
+
const pact = new FakeHttpPact();
|
|
55
|
+
let saw: string | undefined;
|
|
56
|
+
await withHttpPactInteraction(
|
|
57
|
+
pact,
|
|
58
|
+
sampleInteraction,
|
|
59
|
+
async (server) => {
|
|
60
|
+
saw = server.url;
|
|
61
|
+
},
|
|
62
|
+
{ runId: 'r-http-pass' },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(saw).toBe('http://127.0.0.1:9999');
|
|
66
|
+
expect(pact.added).toHaveLength(1);
|
|
67
|
+
expect(pact.added[0]!.uponReceiving).toBe('get all products');
|
|
68
|
+
|
|
69
|
+
const entries = readLedger({ runId: 'r-http-pass' });
|
|
70
|
+
expect(entries).toHaveLength(1);
|
|
71
|
+
expect(entries[0]).toMatchObject({
|
|
72
|
+
consumer: 'Web',
|
|
73
|
+
provider: 'Catalog',
|
|
74
|
+
interaction: 'get all products',
|
|
75
|
+
states: ['products exist'],
|
|
76
|
+
kind: 'http',
|
|
77
|
+
outcome: 'passed',
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('records a failed entry when the test body throws', async () => {
|
|
82
|
+
const pact = new FakeHttpPact();
|
|
83
|
+
await expect(
|
|
84
|
+
withHttpPactInteraction(
|
|
85
|
+
pact,
|
|
86
|
+
sampleInteraction,
|
|
87
|
+
async () => {
|
|
88
|
+
throw new Error('assertion failed');
|
|
89
|
+
},
|
|
90
|
+
{ runId: 'r-http-fail' },
|
|
91
|
+
),
|
|
92
|
+
).rejects.toThrow('assertion failed');
|
|
93
|
+
|
|
94
|
+
const entries = readLedger({ runId: 'r-http-fail' });
|
|
95
|
+
expect(entries[0]).toMatchObject({ outcome: 'failed', error: 'assertion failed' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('records a failed entry when pact.executeTest itself throws', async () => {
|
|
99
|
+
const pact = new FakeHttpPact(true);
|
|
100
|
+
await expect(
|
|
101
|
+
withHttpPactInteraction(pact, sampleInteraction, async () => {}, {
|
|
102
|
+
runId: 'r-http-exec-fail',
|
|
103
|
+
}),
|
|
104
|
+
).rejects.toThrow('mock server failed to start');
|
|
105
|
+
|
|
106
|
+
const entries = readLedger({ runId: 'r-http-exec-fail' });
|
|
107
|
+
expect(entries[0]!.outcome).toBe('failed');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects interactionId on HTTP with a clear error (no metadata channel in PactV3)', async () => {
|
|
111
|
+
const pact = new FakeHttpPact();
|
|
112
|
+
await expect(
|
|
113
|
+
withHttpPactInteraction(pact, sampleInteraction, async () => {}, {
|
|
114
|
+
runId: 'r-http-iid',
|
|
115
|
+
interactionId: 'products.list.v1',
|
|
116
|
+
}),
|
|
117
|
+
).rejects.toThrow(/interactionId.*not yet supported for HTTP/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('throws helpful error when consumer/provider cannot be resolved', async () => {
|
|
121
|
+
const bare: HttpPactLike = {
|
|
122
|
+
addInteraction: () => {},
|
|
123
|
+
async executeTest(fn) {
|
|
124
|
+
return fn({ url: 'http://localhost', port: 0 });
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
await expect(
|
|
128
|
+
withHttpPactInteraction(bare, sampleInteraction, async () => {}, {
|
|
129
|
+
runId: 'r-http-no-cfg',
|
|
130
|
+
}),
|
|
131
|
+
).rejects.toThrow(/could not resolve consumer\/provider/);
|
|
132
|
+
});
|
|
133
|
+
});
|