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,82 @@
1
+ import {
2
+ LEDGER_ENTRY_SPEC,
3
+ type InteractionLedgerEntry,
4
+ type LedgerRecord,
5
+ type ProviderVerificationRunEntry,
6
+ } from './types.js';
7
+
8
+ function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === 'object' && value !== null;
10
+ }
11
+
12
+ /**
13
+ * Validate a parsed JSONL line and return a typed ledger record.
14
+ * Rejects anything that does not match the current v0.2 spec.
15
+ */
16
+ export function normalizeLedgerRecord(parsed: unknown): LedgerRecord | null {
17
+ if (!isRecord(parsed) || parsed.spec !== LEDGER_ENTRY_SPEC) return null;
18
+
19
+ const { consumer, provider, observed_at } = parsed;
20
+ if (
21
+ typeof consumer !== 'string' ||
22
+ typeof provider !== 'string' ||
23
+ typeof observed_at !== 'string'
24
+ ) {
25
+ return null;
26
+ }
27
+
28
+ if (parsed.type === 'provider_verification_run') {
29
+ if (typeof parsed.error !== 'string') return null;
30
+ const entry: ProviderVerificationRunEntry = {
31
+ type: 'provider_verification_run',
32
+ spec: LEDGER_ENTRY_SPEC,
33
+ consumer,
34
+ provider,
35
+ outcome: 'failed',
36
+ source: parsed.source === 'production' ? 'production' : 'test',
37
+ role: 'provider',
38
+ observed_at,
39
+ error: parsed.error,
40
+ };
41
+ if (typeof parsed.run_id === 'string') entry.run_id = parsed.run_id;
42
+ if (typeof parsed.git_sha === 'string') entry.git_sha = parsed.git_sha;
43
+ if (typeof parsed.trace_id === 'string') entry.trace_id = parsed.trace_id;
44
+ if (typeof parsed.span_id === 'string') entry.span_id = parsed.span_id;
45
+ return entry;
46
+ }
47
+
48
+ if (typeof parsed.interaction !== 'string') return null;
49
+
50
+ const states = Array.isArray(parsed.states)
51
+ ? parsed.states.filter((s): s is string => typeof s === 'string')
52
+ : [];
53
+
54
+ const entry: InteractionLedgerEntry = {
55
+ type: 'interaction',
56
+ spec: LEDGER_ENTRY_SPEC,
57
+ consumer,
58
+ provider,
59
+ interaction: parsed.interaction,
60
+ states,
61
+ kind: parsed.kind === 'http' ? 'http' : 'message',
62
+ outcome: parsed.outcome === 'failed' ? 'failed' : 'passed',
63
+ source: parsed.source === 'production' ? 'production' : 'test',
64
+ role: parsed.role === 'provider' ? 'provider' : 'consumer',
65
+ duration_ms:
66
+ typeof parsed.duration_ms === 'number' && parsed.duration_ms >= 0
67
+ ? parsed.duration_ms
68
+ : 0,
69
+ observed_at,
70
+ };
71
+
72
+ if (typeof parsed.interaction_id === 'string' && parsed.interaction_id.length > 0) {
73
+ entry.interaction_id = parsed.interaction_id;
74
+ }
75
+ if (typeof parsed.trace_id === 'string') entry.trace_id = parsed.trace_id;
76
+ if (typeof parsed.span_id === 'string') entry.span_id = parsed.span_id;
77
+ if (typeof parsed.run_id === 'string') entry.run_id = parsed.run_id;
78
+ if (typeof parsed.git_sha === 'string') entry.git_sha = parsed.git_sha;
79
+ if (typeof parsed.error === 'string') entry.error = parsed.error;
80
+
81
+ return entry;
82
+ }
@@ -0,0 +1,92 @@
1
+ import { mkdtempSync, readFileSync, 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 { appendLedgerEntry, ledgerPath, readLedger } from './ledger.js';
6
+ import { isInteractionLedgerEntry, type InteractionLedgerEntry } from './types.js';
7
+
8
+ let workDir: string;
9
+ let originalCwd: string;
10
+
11
+ beforeEach(() => {
12
+ originalCwd = process.cwd();
13
+ workDir = mkdtempSync(path.join(tmpdir(), 'autotel-pact-test-'));
14
+ process.chdir(workDir);
15
+ });
16
+
17
+ afterEach(() => {
18
+ process.chdir(originalCwd);
19
+ rmSync(workDir, { recursive: true, force: true });
20
+ delete process.env.AUTOTEL_PACT_RUN_ID;
21
+ delete process.env.AUTOTEL_PACT_LEDGER_DIR;
22
+ });
23
+
24
+ import { LEDGER_ENTRY_SPEC } from './types.js';
25
+
26
+ function sampleEntry(overrides: Partial<InteractionLedgerEntry> = {}): InteractionLedgerEntry {
27
+ return {
28
+ type: 'interaction',
29
+ spec: LEDGER_ENTRY_SPEC,
30
+ consumer: 'A',
31
+ provider: 'B',
32
+ interaction: 'evt',
33
+ states: [],
34
+ kind: 'message',
35
+ source: 'test',
36
+ role: 'consumer',
37
+ outcome: 'passed',
38
+ duration_ms: 1,
39
+ observed_at: new Date().toISOString(),
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ describe('ledger', () => {
45
+ it('writes JSONL entries that round-trip via readLedger', () => {
46
+ const opts = { runId: 'r1' };
47
+ appendLedgerEntry(sampleEntry({ interaction: 'one' }), opts);
48
+ appendLedgerEntry(sampleEntry({ interaction: 'two' }), opts);
49
+
50
+ const entries = readLedger(opts);
51
+ expect(entries).toHaveLength(2);
52
+ expect(entries.map((e) => (isInteractionLedgerEntry(e) ? e.interaction : ''))).toEqual([
53
+ 'one',
54
+ 'two',
55
+ ]);
56
+ });
57
+
58
+ it('skips malformed lines silently', () => {
59
+ const opts = { runId: 'r2' };
60
+ appendLedgerEntry(sampleEntry({ interaction: 'good' }), opts);
61
+ const path = ledgerPath(opts);
62
+ // simulate corruption
63
+ const { appendFileSync } = require('node:fs');
64
+ appendFileSync(path, 'not-json\n', 'utf8');
65
+
66
+ const entries = readLedger(opts);
67
+ expect(entries).toHaveLength(1);
68
+ expect(isInteractionLedgerEntry(entries[0]!) && entries[0].interaction).toBe('good');
69
+ });
70
+
71
+ it('reads multiple ledger files in the directory', () => {
72
+ appendLedgerEntry(sampleEntry({ interaction: 'a' }), { runId: 'r-a' });
73
+ appendLedgerEntry(sampleEntry({ interaction: 'b' }), { runId: 'r-b' });
74
+
75
+ const entries = readLedger({});
76
+ expect(
77
+ entries
78
+ .map((e) => (isInteractionLedgerEntry(e) ? e.interaction : ''))
79
+ .toSorted(),
80
+ ).toEqual(['a', 'b']);
81
+ });
82
+
83
+ it('returns an empty array when the ledger directory does not exist', () => {
84
+ expect(readLedger({ dir: '.does-not-exist' })).toEqual([]);
85
+ });
86
+
87
+ it('honours AUTOTEL_PACT_RUN_ID', () => {
88
+ process.env.AUTOTEL_PACT_RUN_ID = 'env-run';
89
+ const path = ledgerPath({});
90
+ expect(path).toMatch(/ledger-env-run\.jsonl$/);
91
+ });
92
+ });
package/src/ledger.ts ADDED
@@ -0,0 +1,156 @@
1
+ import {
2
+ appendFileSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ existsSync,
7
+ } from 'node:fs';
8
+ import { appendFile, mkdir } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { normalizeLedgerRecord } from './ledger-normalize.js';
11
+ import { LEDGER_ENTRY_SPEC, type LedgerRecord } from './types.js';
12
+
13
+ export interface LedgerOptions {
14
+ dir?: string;
15
+ runId?: string;
16
+ }
17
+
18
+ const DEFAULT_DIR = '.autotel-pact';
19
+
20
+ function resolveLedgerDir(opts: LedgerOptions = {}): string {
21
+ return path.resolve(process.cwd(), opts.dir ?? process.env.AUTOTEL_PACT_LEDGER_DIR ?? DEFAULT_DIR);
22
+ }
23
+
24
+ function resolveRunId(opts: LedgerOptions = {}): string {
25
+ const explicit = opts.runId ?? process.env.AUTOTEL_PACT_RUN_ID;
26
+ if (explicit) return explicit;
27
+ return `local-${new Date().toISOString().replaceAll(/[:.]/g, '-')}`;
28
+ }
29
+
30
+ export function ledgerPath(opts: LedgerOptions = {}): string {
31
+ const dir = resolveLedgerDir(opts);
32
+ return path.join(dir, `ledger-${resolveRunId(opts)}.jsonl`);
33
+ }
34
+
35
+ function writeLine(filePath: string, entry: LedgerRecord): void {
36
+ mkdirSync(path.dirname(filePath), { recursive: true });
37
+ appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf8');
38
+ }
39
+
40
+ /**
41
+ * Append a ledger record synchronously (tests and consumer wrappers).
42
+ */
43
+ export function appendLedgerEntry(
44
+ entry: LedgerRecord,
45
+ opts: LedgerOptions = {},
46
+ ): void {
47
+ const filePath = ledgerPath(opts);
48
+ const normalized: LedgerRecord =
49
+ entry.type === 'provider_verification_run'
50
+ ? entry
51
+ : { ...entry, spec: LEDGER_ENTRY_SPEC, type: 'interaction' as const };
52
+ writeLine(filePath, normalized);
53
+ }
54
+
55
+ export function appendProviderVerificationFailure(
56
+ entry: Omit<
57
+ import('./types.js').ProviderVerificationRunEntry,
58
+ 'type' | 'spec' | 'outcome' | 'role'
59
+ > & { error: string },
60
+ opts: LedgerOptions = {},
61
+ ): void {
62
+ appendLedgerEntry(
63
+ {
64
+ type: 'provider_verification_run',
65
+ spec: LEDGER_ENTRY_SPEC,
66
+ outcome: 'failed',
67
+ role: 'provider',
68
+ source: entry.source ?? 'test',
69
+ consumer: entry.consumer,
70
+ provider: entry.provider,
71
+ observed_at: entry.observed_at,
72
+ error: entry.error,
73
+ run_id: entry.run_id,
74
+ git_sha: entry.git_sha,
75
+ trace_id: entry.trace_id,
76
+ span_id: entry.span_id,
77
+ },
78
+ opts,
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Read all ledger files and return normalized records.
84
+ */
85
+ export function readLedger(opts: LedgerOptions = {}): LedgerRecord[] {
86
+ const dir = resolveLedgerDir(opts);
87
+ if (!existsSync(dir)) return [];
88
+ const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
89
+ const entries: LedgerRecord[] = [];
90
+ for (const file of files) {
91
+ const text = readFileSync(path.join(dir, file), 'utf8');
92
+ for (const line of text.split('\n')) {
93
+ if (!line.trim()) continue;
94
+ try {
95
+ const normalized = normalizeLedgerRecord(JSON.parse(line));
96
+ if (normalized) entries.push(normalized);
97
+ } catch {
98
+ // skip malformed lines
99
+ }
100
+ }
101
+ }
102
+ return entries;
103
+ }
104
+
105
+ /**
106
+ * Serialized async writes for production span processor.
107
+ * Bounded by a producer-side backpressure threshold: once `pendingWrites`
108
+ * reaches `MAX_PENDING_WRITES`, new callers await drainage before queueing,
109
+ * so memory cannot grow unbounded under sustained pressure.
110
+ */
111
+ const MAX_PENDING_WRITES = 4096;
112
+ let asyncWriteChain: Promise<void> = Promise.resolve();
113
+ let pendingWrites = 0;
114
+
115
+ export async function appendLedgerEntryAsync(
116
+ entry: LedgerRecord,
117
+ opts: LedgerOptions = {},
118
+ ): Promise<void> {
119
+ if (pendingWrites >= MAX_PENDING_WRITES) {
120
+ await asyncWriteChain;
121
+ }
122
+
123
+ const filePath = ledgerPath(opts);
124
+ const normalized: LedgerRecord =
125
+ entry.type === 'provider_verification_run'
126
+ ? entry
127
+ : { ...entry, spec: LEDGER_ENTRY_SPEC, type: 'interaction' as const };
128
+ const line = JSON.stringify(normalized);
129
+
130
+ pendingWrites++;
131
+ const run = asyncWriteChain.then(async () => {
132
+ try {
133
+ await mkdir(path.dirname(filePath), { recursive: true });
134
+ await appendFile(filePath, line + '\n', 'utf8');
135
+ } finally {
136
+ pendingWrites--;
137
+ }
138
+ });
139
+ asyncWriteChain = run.catch(() => {});
140
+ return run;
141
+ }
142
+
143
+ export async function flushLedgerWrites(): Promise<void> {
144
+ await asyncWriteChain;
145
+ }
146
+
147
+ /** @internal Reset async chain between tests. */
148
+ export function resetLedgerWriteChainForTests(): void {
149
+ asyncWriteChain = Promise.resolve();
150
+ pendingWrites = 0;
151
+ }
152
+
153
+ /** @internal Expose pending write count for tests. */
154
+ export function pendingLedgerWriteCount(): number {
155
+ return pendingWrites;
156
+ }
@@ -0,0 +1,124 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } 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 {
6
+ extractInteractionId,
7
+ interactionsFromPactFile,
8
+ listPactFiles,
9
+ parsePactFile,
10
+ } from './pact-file.js';
11
+
12
+ let dir: string;
13
+
14
+ beforeEach(() => {
15
+ dir = mkdtempSync(path.join(tmpdir(), 'autotel-pact-file-'));
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ });
21
+
22
+ describe('listPactFiles', () => {
23
+ it('returns [] for missing directory', () => {
24
+ expect(listPactFiles(path.join(dir, 'does-not-exist'))).toEqual([]);
25
+ });
26
+
27
+ it('lists json files in a flat directory', () => {
28
+ writeFileSync(path.join(dir, 'a.json'), '{}');
29
+ writeFileSync(path.join(dir, 'b.json'), '{}');
30
+ writeFileSync(path.join(dir, 'ignore.txt'), 'no');
31
+ const files = listPactFiles(dir).map((f) => path.basename(f)).toSorted();
32
+ expect(files).toEqual(['a.json', 'b.json']);
33
+ });
34
+
35
+ it('recurses into nested directories', () => {
36
+ mkdirSync(path.join(dir, 'nested'));
37
+ writeFileSync(path.join(dir, 'nested', 'c.json'), '{}');
38
+ writeFileSync(path.join(dir, 'top.json'), '{}');
39
+ const files = listPactFiles(dir).map((f) => path.basename(f)).toSorted();
40
+ expect(files).toEqual(['c.json', 'top.json']);
41
+ });
42
+ });
43
+
44
+ describe('parsePactFile', () => {
45
+ it('returns parsed object for valid JSON', () => {
46
+ const file = path.join(dir, 'p.json');
47
+ writeFileSync(file, JSON.stringify({ consumer: { name: 'A' }, provider: { name: 'B' } }));
48
+ expect(parsePactFile(file)?.consumer.name).toBe('A');
49
+ });
50
+
51
+ it('returns null for malformed JSON', () => {
52
+ const file = path.join(dir, 'bad.json');
53
+ writeFileSync(file, '{ this is not json');
54
+ expect(parsePactFile(file)).toBeNull();
55
+ });
56
+
57
+ it('returns null for missing file', () => {
58
+ expect(parsePactFile(path.join(dir, 'nope.json'))).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe('interactionsFromPactFile', () => {
63
+ it('extracts message interactions', () => {
64
+ const keys = interactionsFromPactFile({
65
+ consumer: { name: 'A' },
66
+ provider: { name: 'B' },
67
+ messages: [
68
+ { description: 'evt1', providerStates: [{ name: 'state' }] },
69
+ { description: 'evt2' },
70
+ ],
71
+ });
72
+ expect(keys).toEqual([
73
+ { consumer: 'A', provider: 'B', interaction: 'evt1', kind: 'message', interactionId: undefined },
74
+ { consumer: 'A', provider: 'B', interaction: 'evt2', kind: 'message', interactionId: undefined },
75
+ ]);
76
+ });
77
+
78
+ it('extracts http interactions', () => {
79
+ const keys = interactionsFromPactFile({
80
+ consumer: { name: 'A' },
81
+ provider: { name: 'B' },
82
+ interactions: [{ description: 'GET /orders' }],
83
+ });
84
+ expect(keys).toEqual([
85
+ { consumer: 'A', provider: 'B', interaction: 'GET /orders', kind: 'http', interactionId: undefined },
86
+ ]);
87
+ });
88
+
89
+ it('returns [] when consumer or provider missing', () => {
90
+ expect(
91
+ interactionsFromPactFile({
92
+ consumer: { name: '' },
93
+ provider: { name: 'B' },
94
+ messages: [{ description: 'x' }],
95
+ }),
96
+ ).toEqual([]);
97
+ });
98
+
99
+ it('picks up interactionId from metadata', () => {
100
+ const keys = interactionsFromPactFile({
101
+ consumer: { name: 'A' },
102
+ provider: { name: 'B' },
103
+ messages: [
104
+ { description: 'evt', metadata: { interactionId: 'iid-1' } },
105
+ ],
106
+ });
107
+ expect(keys[0]?.interactionId).toBe('iid-1');
108
+ });
109
+ });
110
+
111
+ describe('extractInteractionId', () => {
112
+ it('accepts snake_case and camelCase', () => {
113
+ expect(extractInteractionId({ interaction_id: 'snake' })).toBe('snake');
114
+ expect(extractInteractionId({ interactionId: 'camel' })).toBe('camel');
115
+ });
116
+
117
+ it('returns undefined for non-string or empty', () => {
118
+ expect(extractInteractionId({})).toBeUndefined();
119
+ expect(extractInteractionId({ interactionId: '' })).toBeUndefined();
120
+ expect(extractInteractionId({ interactionId: 42 })).toBeUndefined();
121
+ // eslint-disable-next-line unicorn/no-useless-undefined
122
+ expect(extractInteractionId(undefined)).toBeUndefined();
123
+ });
124
+ });
@@ -0,0 +1,65 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { PactFile, PactInteractionKey } from './types.js';
4
+
5
+ /**
6
+ * Walk a directory and return absolute paths of all *.json pact files.
7
+ */
8
+ export function listPactFiles(dir: string): string[] {
9
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
10
+ const out: string[] = [];
11
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
12
+ const full = path.join(dir, entry.name);
13
+ if (entry.isDirectory()) {
14
+ out.push(...listPactFiles(full));
15
+ } else if (entry.isFile() && entry.name.endsWith('.json')) {
16
+ out.push(full);
17
+ }
18
+ }
19
+ return out;
20
+ }
21
+
22
+ export function extractInteractionId(
23
+ metadata: Record<string, unknown> | undefined,
24
+ ): string | undefined {
25
+ if (!metadata) return undefined;
26
+ const id = metadata.interactionId ?? metadata.interaction_id;
27
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
28
+ }
29
+
30
+ /**
31
+ * Extract interaction tuples declared in a pact file.
32
+ */
33
+ export function interactionsFromPactFile(pact: PactFile): PactInteractionKey[] {
34
+ const consumer = pact.consumer?.name;
35
+ const provider = pact.provider?.name;
36
+ if (!consumer || !provider) return [];
37
+ const keys: PactInteractionKey[] = [];
38
+ for (const m of pact.messages ?? []) {
39
+ keys.push({
40
+ consumer,
41
+ provider,
42
+ interaction: m.description,
43
+ kind: 'message',
44
+ interactionId: extractInteractionId(m.metadata),
45
+ });
46
+ }
47
+ for (const i of pact.interactions ?? []) {
48
+ keys.push({
49
+ consumer,
50
+ provider,
51
+ interaction: i.description,
52
+ kind: 'http',
53
+ interactionId: extractInteractionId(i.metadata),
54
+ });
55
+ }
56
+ return keys;
57
+ }
58
+
59
+ export function parsePactFile(filePath: string): PactFile | null {
60
+ try {
61
+ return JSON.parse(readFileSync(filePath, 'utf8')) as PactFile;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
@@ -0,0 +1,90 @@
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, vi } from 'vitest';
5
+ import { PACT_ATTRS } from './attrs.js';
6
+ import { flushLedgerWrites, readLedger, resetLedgerWriteChainForTests } from './ledger.js';
7
+ import { PactLedgerSpanProcessor } from './processor.js';
8
+
9
+ let workDir: string;
10
+ let originalCwd: string;
11
+
12
+ beforeEach(() => {
13
+ originalCwd = process.cwd();
14
+ workDir = mkdtempSync(path.join(tmpdir(), 'autotel-pact-proc-'));
15
+ process.chdir(workDir);
16
+ resetLedgerWriteChainForTests();
17
+ });
18
+
19
+ afterEach(() => {
20
+ process.chdir(originalCwd);
21
+ rmSync(workDir, { recursive: true, force: true });
22
+ });
23
+
24
+ function fakeSpan(attrs: Record<string, unknown>) {
25
+ return {
26
+ attributes: attrs,
27
+ spanContext: () => ({ traceId: 't1', spanId: 's1' }),
28
+ };
29
+ }
30
+
31
+ describe('PactLedgerSpanProcessor', () => {
32
+ it('writes production ledger entries for pact-tagged spans', async () => {
33
+ const proc = new PactLedgerSpanProcessor({ runId: 'prod-1' });
34
+ proc.onEnd(
35
+ fakeSpan({
36
+ [PACT_ATTRS.CONSUMER]: 'A',
37
+ [PACT_ATTRS.PROVIDER]: 'B',
38
+ [PACT_ATTRS.INTERACTION_DESCRIPTION]: 'evt',
39
+ [PACT_ATTRS.KIND]: 'message',
40
+ }),
41
+ );
42
+ await proc.forceFlush();
43
+
44
+ const entries = readLedger({ runId: 'prod-1' });
45
+ expect(entries).toHaveLength(1);
46
+ if (entries[0]!.type === 'provider_verification_run') throw new Error('expected interaction');
47
+ expect(entries[0]).toMatchObject({
48
+ source: 'production',
49
+ role: 'consumer',
50
+ interaction: 'evt',
51
+ });
52
+ });
53
+
54
+ it('does not throw when ledger write fails', async () => {
55
+ const proc = new PactLedgerSpanProcessor({
56
+ runId: 'x',
57
+ dir: '/\0invalid',
58
+ onWriteError: vi.fn(),
59
+ });
60
+ expect(() =>
61
+ proc.onEnd(
62
+ fakeSpan({
63
+ [PACT_ATTRS.CONSUMER]: 'A',
64
+ [PACT_ATTRS.PROVIDER]: 'B',
65
+ [PACT_ATTRS.INTERACTION_DESCRIPTION]: 'evt',
66
+ }),
67
+ ),
68
+ ).not.toThrow();
69
+ await proc.forceFlush();
70
+ });
71
+
72
+ it('drops oldest when queue is full', async () => {
73
+ const onDrop = vi.fn();
74
+ const proc = new PactLedgerSpanProcessor({
75
+ runId: 'drop',
76
+ maxQueueSize: 1,
77
+ onDrop,
78
+ onWarn: () => {},
79
+ });
80
+ const span = fakeSpan({
81
+ [PACT_ATTRS.CONSUMER]: 'A',
82
+ [PACT_ATTRS.PROVIDER]: 'B',
83
+ [PACT_ATTRS.INTERACTION_DESCRIPTION]: 'evt',
84
+ });
85
+ proc.onEnd(span);
86
+ proc.onEnd(span);
87
+ await proc.forceFlush();
88
+ expect(onDrop).toHaveBeenCalled();
89
+ });
90
+ });