autotel-eventcatalog 1.0.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/CHANGELOG.md +196 -0
- package/CONTRIBUTING.md +212 -0
- package/README.md +307 -0
- package/action.yml +155 -0
- package/dist/cli.cjs +1071 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1065 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +794 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +267 -0
- package/dist/index.d.ts +267 -0
- package/dist/index.js +764 -0
- package/dist/index.js.map +1 -0
- package/docs/CONTRACT.md +280 -0
- package/docs/EXTENDING.md +248 -0
- package/docs/TROUBLESHOOTING.md +220 -0
- package/docs/UPGRADING.md +202 -0
- package/package.json +78 -0
- package/schemas/README.md +44 -0
- package/schemas/drift-report-v0.1.0.json +107 -0
- package/schemas/drift-report-v0.2.0.json +137 -0
- package/schemas/drift-summary-v0.1.0.json +74 -0
- package/schemas/drift-summary-v0.2.0.json +74 -0
- package/schemas/stamp-summary-v0.1.0.json +54 -0
- package/src/__fixtures__/drift-report-all.golden.json +33 -0
- package/src/__fixtures__/drift-summary-clean.golden.json +17 -0
- package/src/__fixtures__/drift-summary-drifty.golden.json +17 -0
- package/src/__fixtures__/stamp-summary-noop.golden.json +10 -0
- package/src/catalog.test.ts +63 -0
- package/src/catalog.ts +169 -0
- package/src/cli.e2e.test.ts +310 -0
- package/src/cli.ts +402 -0
- package/src/contract.test.ts +395 -0
- package/src/diff-vs-base.test.ts +145 -0
- package/src/diff-vs-base.ts +242 -0
- package/src/diff.test.ts +384 -0
- package/src/diff.ts +296 -0
- package/src/index.ts +73 -0
- package/src/policy.test.ts +75 -0
- package/src/policy.ts +41 -0
- package/src/renderers/index.ts +35 -0
- package/src/renderers/json.ts +33 -0
- package/src/renderers/markdown.ts +223 -0
- package/src/renderers/renderers.test.ts +79 -0
- package/src/renderers/terminal.ts +30 -0
- package/src/renderers/types.ts +26 -0
- package/src/report.test.ts +205 -0
- package/src/report.ts +27 -0
- package/src/snapshot.ts +25 -0
- package/src/stamp.test.ts +283 -0
- package/src/stamp.ts +232 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// Contract tests for the public JSON surface.
|
|
2
|
+
//
|
|
3
|
+
// These are *not* about whether the renderer produces nice-looking output —
|
|
4
|
+
// `report.test.ts` covers that. These tests treat the JSON shape as a public
|
|
5
|
+
// contract that downstream tooling (the GitHub Action, Slack bots, dashboards)
|
|
6
|
+
// can depend on. A failure here should read as:
|
|
7
|
+
//
|
|
8
|
+
// "You changed the published contract for vX.Y.Z. Either revert the change
|
|
9
|
+
// or bump the spec version and update consumers."
|
|
10
|
+
//
|
|
11
|
+
// We deliberately avoid taking an ajv dependency for runtime validation; the
|
|
12
|
+
// schemas are tiny and the validator below is hand-rolled in ~60 lines. The
|
|
13
|
+
// JSON Schema files in `schemas/` are the shipped artifact that consumers can
|
|
14
|
+
// validate against using whichever validator they prefer.
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
import { REPORT_SPEC, renderJson, type JsonReportEnvelope } from './report';
|
|
22
|
+
import { buildStampSummary, STAMP_SUMMARY_SPEC } from './stamp';
|
|
23
|
+
import { evaluatePolicy } from './policy';
|
|
24
|
+
import { countDriftReport } from './diff';
|
|
25
|
+
import type { DriftReport } from './diff';
|
|
26
|
+
import type { DriftDelta } from './diff-vs-base';
|
|
27
|
+
|
|
28
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const SCHEMAS = join(HERE, '..', 'schemas');
|
|
30
|
+
|
|
31
|
+
type JsonSchema = {
|
|
32
|
+
required?: string[];
|
|
33
|
+
oneOf?: JsonSchema[];
|
|
34
|
+
allOf?: JsonSchema[];
|
|
35
|
+
properties?: Record<string, JsonSchema>;
|
|
36
|
+
additionalProperties?: boolean;
|
|
37
|
+
type?: string;
|
|
38
|
+
const?: unknown;
|
|
39
|
+
enum?: unknown[];
|
|
40
|
+
items?: JsonSchema;
|
|
41
|
+
minimum?: number;
|
|
42
|
+
$ref?: string;
|
|
43
|
+
$defs?: Record<string, JsonSchema>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Minimal JSON Schema validator. Supports the subset of keywords used in
|
|
48
|
+
* our shipped schemas: type, const, enum, required, properties,
|
|
49
|
+
* additionalProperties, items, minimum, oneOf, allOf, $defs / $ref.
|
|
50
|
+
*
|
|
51
|
+
* Throws on unsupported keywords so we notice if we drift past the subset.
|
|
52
|
+
*/
|
|
53
|
+
function validate(
|
|
54
|
+
data: unknown,
|
|
55
|
+
schema: JsonSchema,
|
|
56
|
+
defs: Record<string, JsonSchema> = schema.$defs ?? {},
|
|
57
|
+
path = '$',
|
|
58
|
+
): string[] {
|
|
59
|
+
const errors: string[] = [];
|
|
60
|
+
|
|
61
|
+
if (schema.$ref) {
|
|
62
|
+
const name = schema.$ref.replace('#/$defs/', '');
|
|
63
|
+
const target = defs[name];
|
|
64
|
+
if (!target) {
|
|
65
|
+
errors.push(`${path}: unresolved $ref ${schema.$ref}`);
|
|
66
|
+
return errors;
|
|
67
|
+
}
|
|
68
|
+
return validate(data, target, defs, path);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (schema.oneOf) {
|
|
72
|
+
const matched = schema.oneOf.filter(
|
|
73
|
+
(s) => validate(data, s, defs, path).length === 0,
|
|
74
|
+
);
|
|
75
|
+
if (matched.length !== 1) {
|
|
76
|
+
errors.push(
|
|
77
|
+
`${path}: expected to match exactly one oneOf branch, matched ${matched.length}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return errors;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (schema.allOf) {
|
|
84
|
+
for (const s of schema.allOf) errors.push(...validate(data, s, defs, path));
|
|
85
|
+
return errors;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (schema.const !== undefined && data !== schema.const) {
|
|
89
|
+
errors.push(
|
|
90
|
+
`${path}: expected const ${JSON.stringify(schema.const)}, got ${JSON.stringify(data)}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (schema.enum && !schema.enum.includes(data)) {
|
|
95
|
+
errors.push(
|
|
96
|
+
`${path}: value ${JSON.stringify(data)} not in enum ${JSON.stringify(schema.enum)}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (schema.type) {
|
|
101
|
+
const actual =
|
|
102
|
+
data === null
|
|
103
|
+
? 'null'
|
|
104
|
+
: Array.isArray(data)
|
|
105
|
+
? 'array'
|
|
106
|
+
: typeof data === 'number' && Number.isInteger(data)
|
|
107
|
+
? 'integer'
|
|
108
|
+
: typeof data;
|
|
109
|
+
const ok =
|
|
110
|
+
(schema.type === 'integer' && actual === 'integer') ||
|
|
111
|
+
(schema.type === 'number' &&
|
|
112
|
+
(actual === 'number' || actual === 'integer')) ||
|
|
113
|
+
schema.type === actual;
|
|
114
|
+
if (!ok)
|
|
115
|
+
errors.push(`${path}: expected type ${schema.type}, got ${actual}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (
|
|
119
|
+
schema.minimum !== undefined &&
|
|
120
|
+
typeof data === 'number' &&
|
|
121
|
+
data < schema.minimum
|
|
122
|
+
) {
|
|
123
|
+
errors.push(`${path}: ${data} < minimum ${schema.minimum}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (schema.required && typeof data === 'object' && data !== null) {
|
|
127
|
+
for (const key of schema.required) {
|
|
128
|
+
if (!(key in (data as Record<string, unknown>))) {
|
|
129
|
+
errors.push(`${path}: missing required property "${key}"`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (schema.properties && typeof data === 'object' && data !== null) {
|
|
135
|
+
const obj = data as Record<string, unknown>;
|
|
136
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
137
|
+
if (key in obj) {
|
|
138
|
+
errors.push(...validate(obj[key], sub, defs, `${path}.${key}`));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (schema.additionalProperties === false) {
|
|
142
|
+
for (const key of Object.keys(obj)) {
|
|
143
|
+
if (!(key in schema.properties)) {
|
|
144
|
+
errors.push(`${path}: unexpected property "${key}"`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (schema.items && Array.isArray(data)) {
|
|
151
|
+
for (let i = 0; i < data.length; i++) {
|
|
152
|
+
errors.push(...validate(data[i], schema.items, defs, `${path}[${i}]`));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return errors;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function loadSchema(name: string): JsonSchema {
|
|
160
|
+
return JSON.parse(readFileSync(join(SCHEMAS, name), 'utf8'));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Fixtures ──────────────────────────────────────────────
|
|
164
|
+
const emptyReport: DriftReport = {
|
|
165
|
+
snapshotGeneratedAt: '2026-05-22T00:00:00.000Z',
|
|
166
|
+
snapshotService: 'fixture',
|
|
167
|
+
events: {
|
|
168
|
+
observedButUndocumented: [],
|
|
169
|
+
documentedButUnseen: [],
|
|
170
|
+
fieldDrift: [],
|
|
171
|
+
typeDrift: [],
|
|
172
|
+
valueDrift: [],
|
|
173
|
+
},
|
|
174
|
+
services: { observedButUndocumented: [] },
|
|
175
|
+
channels: { observedButUndocumented: [] },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const driftyReport: DriftReport = {
|
|
179
|
+
...emptyReport,
|
|
180
|
+
events: {
|
|
181
|
+
observedButUndocumented: ['order.cancelled'],
|
|
182
|
+
documentedButUnseen: ['LegacyEvent'],
|
|
183
|
+
fieldDrift: [
|
|
184
|
+
{
|
|
185
|
+
event: 'recommendation.generated',
|
|
186
|
+
extra: ['personalization_seed'],
|
|
187
|
+
missing: [],
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
typeDrift: [],
|
|
191
|
+
valueDrift: [],
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const cleanDelta: DriftDelta = {
|
|
196
|
+
hasNewDrift: false,
|
|
197
|
+
introduced: {
|
|
198
|
+
events: {
|
|
199
|
+
observedButUndocumented: [],
|
|
200
|
+
documentedButUnseen: [],
|
|
201
|
+
fieldDrift: [],
|
|
202
|
+
typeDrift: [],
|
|
203
|
+
valueDrift: [],
|
|
204
|
+
},
|
|
205
|
+
services: { observedButUndocumented: [] },
|
|
206
|
+
channels: { observedButUndocumented: [] },
|
|
207
|
+
},
|
|
208
|
+
resolved: {
|
|
209
|
+
events: {
|
|
210
|
+
observedButUndocumented: [],
|
|
211
|
+
documentedButUnseen: [],
|
|
212
|
+
fieldDrift: [],
|
|
213
|
+
typeDrift: [],
|
|
214
|
+
valueDrift: [],
|
|
215
|
+
},
|
|
216
|
+
services: { observedButUndocumented: [] },
|
|
217
|
+
channels: { observedButUndocumented: [] },
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// ─── Contract: drift report JSON envelope ──────────────────
|
|
222
|
+
describe('drift report JSON envelope (autotel-eventcatalog-report/v0.2.0)', () => {
|
|
223
|
+
const schema = loadSchema('drift-report-v0.2.0.json');
|
|
224
|
+
|
|
225
|
+
it('mode=all output validates against the published schema', () => {
|
|
226
|
+
const json = renderJson({ mode: 'all', report: driftyReport });
|
|
227
|
+
const parsed = JSON.parse(json);
|
|
228
|
+
expect(validate(parsed, schema)).toEqual([]);
|
|
229
|
+
expect(parsed.spec).toBe(REPORT_SPEC);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('mode=new-only output validates against the published schema', () => {
|
|
233
|
+
const json = renderJson({ mode: 'new-only', delta: cleanDelta });
|
|
234
|
+
const parsed = JSON.parse(json);
|
|
235
|
+
expect(validate(parsed, schema)).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('byte-equal to golden fixture for a representative drifty report', () => {
|
|
239
|
+
// Lock the exact bytes for the all-mode envelope. If a future change
|
|
240
|
+
// re-orders keys or changes whitespace, this test fails with a clear
|
|
241
|
+
// message: "You changed the published contract."
|
|
242
|
+
const json = renderJson({ mode: 'all', report: driftyReport });
|
|
243
|
+
const golden = readFileSync(
|
|
244
|
+
join(HERE, '__fixtures__', 'drift-report-all.golden.json'),
|
|
245
|
+
'utf8',
|
|
246
|
+
);
|
|
247
|
+
// Goldens are written with a trailing newline (POSIX convention); the
|
|
248
|
+
// renderer omits it. Compare with the trailing newline normalised so the
|
|
249
|
+
// contract is "every byte of the JSON envelope", not "do you end with \n".
|
|
250
|
+
expect(json).toBe(golden.replace(/\n$/, ''));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('spec marker is the only place the version lives — bumping it is a breaking change', () => {
|
|
254
|
+
// Sanity: if someone renames REPORT_SPEC by accident, the published
|
|
255
|
+
// contract changes silently. Pin it to the schema's const here.
|
|
256
|
+
const schemaConst = (
|
|
257
|
+
(schema.oneOf ?? [])[0]?.properties?.spec as
|
|
258
|
+
| { const?: string }
|
|
259
|
+
| undefined
|
|
260
|
+
)?.const;
|
|
261
|
+
expect(REPORT_SPEC).toBe(schemaConst);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ─── Contract: drift summary JSON ──────────────────────────
|
|
266
|
+
describe('drift summary JSON (autotel-eventcatalog-drift-summary/v0.2.0)', () => {
|
|
267
|
+
const schema = loadSchema('drift-summary-v0.2.0.json');
|
|
268
|
+
// The CLI builds the summary inline; we replicate the shape here so the
|
|
269
|
+
// contract test is independent of CLI implementation details. The
|
|
270
|
+
// important thing is that the shape we ship matches the published schema.
|
|
271
|
+
function buildSummary(
|
|
272
|
+
mode: 'all' | 'new-only',
|
|
273
|
+
report: DriftReport,
|
|
274
|
+
): unknown {
|
|
275
|
+
const policy = evaluatePolicy({ mode: 'all', report });
|
|
276
|
+
return {
|
|
277
|
+
spec: 'autotel-eventcatalog-drift-summary/v0.2.0',
|
|
278
|
+
mode,
|
|
279
|
+
shouldFail: policy.shouldFail,
|
|
280
|
+
reason: policy.reason,
|
|
281
|
+
counts: countDriftReport(report),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
it('clean drift summary validates against the schema', () => {
|
|
286
|
+
expect(validate(buildSummary('all', emptyReport), schema)).toEqual([]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('drifty summary validates against the schema', () => {
|
|
290
|
+
expect(validate(buildSummary('all', driftyReport), schema)).toEqual([]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('byte-equal to golden fixture for a clean run', () => {
|
|
294
|
+
const summary = buildSummary('all', emptyReport);
|
|
295
|
+
const json = JSON.stringify(summary, null, 2);
|
|
296
|
+
const golden = readFileSync(
|
|
297
|
+
join(HERE, '__fixtures__', 'drift-summary-clean.golden.json'),
|
|
298
|
+
'utf8',
|
|
299
|
+
);
|
|
300
|
+
expect(json).toBe(golden.trimEnd());
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('byte-equal to golden fixture for a drifty run', () => {
|
|
304
|
+
const summary = buildSummary('all', driftyReport);
|
|
305
|
+
const json = JSON.stringify(summary, null, 2);
|
|
306
|
+
const golden = readFileSync(
|
|
307
|
+
join(HERE, '__fixtures__', 'drift-summary-drifty.golden.json'),
|
|
308
|
+
'utf8',
|
|
309
|
+
);
|
|
310
|
+
expect(json).toBe(golden.trimEnd());
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ─── Contract: stamp summary JSON ──────────────────────────
|
|
315
|
+
describe('stamp summary JSON (autotel-eventcatalog-stamp-summary/v0.1.0)', () => {
|
|
316
|
+
const schema = loadSchema('stamp-summary-v0.1.0.json');
|
|
317
|
+
|
|
318
|
+
it('mixed-results summary validates', () => {
|
|
319
|
+
const summary = buildStampSummary(
|
|
320
|
+
{
|
|
321
|
+
updates: [
|
|
322
|
+
{
|
|
323
|
+
catalogId: 'A',
|
|
324
|
+
snapshotName: 'a',
|
|
325
|
+
filePath: '/x/a.mdx',
|
|
326
|
+
action: 'insert',
|
|
327
|
+
changed: true,
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
catalogId: 'B',
|
|
331
|
+
snapshotName: 'b',
|
|
332
|
+
filePath: '/x/b.mdx',
|
|
333
|
+
action: 'replace',
|
|
334
|
+
changed: false,
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
skips: [{ snapshotName: 'gone', reason: 'no-catalog-match' }],
|
|
338
|
+
},
|
|
339
|
+
false,
|
|
340
|
+
);
|
|
341
|
+
expect(summary.spec).toBe(STAMP_SUMMARY_SPEC);
|
|
342
|
+
expect(validate(summary, schema)).toEqual([]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('byte-equal to golden fixture for a no-op run', () => {
|
|
346
|
+
const summary = buildStampSummary({ updates: [], skips: [] }, false);
|
|
347
|
+
const json = JSON.stringify(summary, null, 2);
|
|
348
|
+
const golden = readFileSync(
|
|
349
|
+
join(HERE, '__fixtures__', 'stamp-summary-noop.golden.json'),
|
|
350
|
+
'utf8',
|
|
351
|
+
);
|
|
352
|
+
expect(json).toBe(golden.trimEnd());
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ─── Validator self-check ─────────────────────────────────
|
|
357
|
+
describe('tiny JSON Schema validator (used by the contract tests above)', () => {
|
|
358
|
+
it('catches missing required properties', () => {
|
|
359
|
+
const errs = validate({ a: 1 }, { required: ['a', 'b'], properties: {} });
|
|
360
|
+
expect(errs).toEqual([
|
|
361
|
+
expect.stringContaining('missing required property "b"'),
|
|
362
|
+
]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('rejects const mismatches', () => {
|
|
366
|
+
const errs = validate(
|
|
367
|
+
{ spec: 'wrong' },
|
|
368
|
+
{
|
|
369
|
+
properties: { spec: { const: 'right' } },
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
expect(errs[0]).toContain('expected const');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('integer vs number distinction', () => {
|
|
376
|
+
expect(validate(1.5, { type: 'integer' })).toHaveLength(1);
|
|
377
|
+
expect(validate(2, { type: 'integer' })).toEqual([]);
|
|
378
|
+
expect(validate(2, { type: 'number' })).toEqual([]);
|
|
379
|
+
expect(validate(1.5, { type: 'number' })).toEqual([]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('rejects additional properties when additionalProperties: false', () => {
|
|
383
|
+
const errs = validate(
|
|
384
|
+
{ a: 1, b: 2 },
|
|
385
|
+
{ properties: { a: { type: 'number' } }, additionalProperties: false },
|
|
386
|
+
);
|
|
387
|
+
expect(errs[0]).toContain('unexpected property "b"');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Silence the unused-envelope-type import without polluting runtime.
|
|
391
|
+
it('exports the envelope type', () => {
|
|
392
|
+
const _envelope = null as unknown as JsonReportEnvelope;
|
|
393
|
+
expect(_envelope).toBe(null);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
compareDriftReports,
|
|
4
|
+
countDriftEntries,
|
|
5
|
+
countDriftDelta,
|
|
6
|
+
} from './diff-vs-base';
|
|
7
|
+
import type { DriftReport } from './diff';
|
|
8
|
+
|
|
9
|
+
function report(
|
|
10
|
+
overrides: Partial<DriftReport['events']> & Partial<DriftReport> = {},
|
|
11
|
+
): DriftReport {
|
|
12
|
+
return {
|
|
13
|
+
snapshotGeneratedAt: '2026-05-22T00:00:00.000Z',
|
|
14
|
+
snapshotService: 'svc',
|
|
15
|
+
events: {
|
|
16
|
+
observedButUndocumented: overrides.observedButUndocumented ?? [],
|
|
17
|
+
documentedButUnseen: overrides.documentedButUnseen ?? [],
|
|
18
|
+
fieldDrift: overrides.fieldDrift ?? [],
|
|
19
|
+
},
|
|
20
|
+
services: { observedButUndocumented: [] },
|
|
21
|
+
channels: { observedButUndocumented: [] },
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('compareDriftReports', () => {
|
|
27
|
+
it('reports nothing when base and head are identical', () => {
|
|
28
|
+
const r = compareDriftReports(report(), report());
|
|
29
|
+
expect(r.hasNewDrift).toBe(false);
|
|
30
|
+
expect(r.introduced.events.observedButUndocumented).toEqual([]);
|
|
31
|
+
expect(r.resolved.events.observedButUndocumented).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('reports newly observed-but-undocumented events as introduced', () => {
|
|
35
|
+
const base = report({ observedButUndocumented: ['existing.event'] });
|
|
36
|
+
const head = report({
|
|
37
|
+
observedButUndocumented: ['existing.event', 'pr.new.event'],
|
|
38
|
+
});
|
|
39
|
+
const r = compareDriftReports(base, head);
|
|
40
|
+
expect(r.introduced.events.observedButUndocumented).toEqual([
|
|
41
|
+
'pr.new.event',
|
|
42
|
+
]);
|
|
43
|
+
expect(r.hasNewDrift).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('reports a drift entry that disappeared as resolved', () => {
|
|
47
|
+
const base = report({
|
|
48
|
+
documentedButUnseen: ['LegacyEvent', 'OtherEvent'],
|
|
49
|
+
});
|
|
50
|
+
const head = report({ documentedButUnseen: ['OtherEvent'] });
|
|
51
|
+
const r = compareDriftReports(base, head);
|
|
52
|
+
expect(r.resolved.events.documentedButUnseen).toEqual(['LegacyEvent']);
|
|
53
|
+
expect(r.hasNewDrift).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reports added field paths inside an existing fieldDrift entry', () => {
|
|
57
|
+
const base = report({
|
|
58
|
+
fieldDrift: [
|
|
59
|
+
{ event: 'order.placed', extra: ['preExisting'], missing: [] },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
const head = report({
|
|
63
|
+
fieldDrift: [
|
|
64
|
+
{
|
|
65
|
+
event: 'order.placed',
|
|
66
|
+
extra: ['preExisting', 'newField'],
|
|
67
|
+
missing: [],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
const r = compareDriftReports(base, head);
|
|
72
|
+
expect(r.introduced.events.fieldDrift).toEqual([
|
|
73
|
+
{ event: 'order.placed', extra: ['newField'], missing: [] },
|
|
74
|
+
]);
|
|
75
|
+
expect(r.hasNewDrift).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('treats a completely new fieldDrift event as introduced', () => {
|
|
79
|
+
const base = report();
|
|
80
|
+
const head = report({
|
|
81
|
+
fieldDrift: [{ event: 'new.event', extra: ['field'], missing: [] }],
|
|
82
|
+
});
|
|
83
|
+
const r = compareDriftReports(base, head);
|
|
84
|
+
expect(r.introduced.events.fieldDrift).toEqual([
|
|
85
|
+
{ event: 'new.event', extra: ['field'], missing: [] },
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('treats a fieldDrift event that disappeared as resolved', () => {
|
|
90
|
+
const base = report({
|
|
91
|
+
fieldDrift: [{ event: 'gone', extra: ['x'], missing: ['y'] }],
|
|
92
|
+
});
|
|
93
|
+
const head = report();
|
|
94
|
+
const r = compareDriftReports(base, head);
|
|
95
|
+
expect(r.resolved.events.fieldDrift).toEqual([
|
|
96
|
+
{ event: 'gone', extra: ['x'], missing: ['y'] },
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('hasNewDrift is true only when introduced has content', () => {
|
|
101
|
+
const onlyResolved = compareDriftReports(
|
|
102
|
+
report({ observedButUndocumented: ['fixed'] }),
|
|
103
|
+
report(),
|
|
104
|
+
);
|
|
105
|
+
expect(onlyResolved.hasNewDrift).toBe(false);
|
|
106
|
+
expect(onlyResolved.resolved.events.observedButUndocumented).toEqual([
|
|
107
|
+
'fixed',
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('countDriftEntries / countDriftDelta', () => {
|
|
113
|
+
it('counts the introduced side of a delta', () => {
|
|
114
|
+
const delta = compareDriftReports(
|
|
115
|
+
report(),
|
|
116
|
+
report({
|
|
117
|
+
observedButUndocumented: ['order.cancelled'],
|
|
118
|
+
fieldDrift: [
|
|
119
|
+
{
|
|
120
|
+
event: 'order.placed',
|
|
121
|
+
extra: ['extra1', 'extra2'],
|
|
122
|
+
missing: ['miss1'],
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
const counts = countDriftEntries(delta.introduced);
|
|
128
|
+
expect(counts.observedButUndocumentedEvents).toBe(1);
|
|
129
|
+
expect(counts.fieldDriftEvents).toBe(1);
|
|
130
|
+
expect(counts.fieldDriftPaths).toBe(3); // 2 extra + 1 missing
|
|
131
|
+
expect(counts.total).toBe(4); // 1 observedButUndoc + 3 fieldDriftPaths
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('countDriftDelta returns both sides', () => {
|
|
135
|
+
const delta = compareDriftReports(
|
|
136
|
+
report({ observedButUndocumented: ['fixed.event'] }),
|
|
137
|
+
report({ observedButUndocumented: ['new.event'] }),
|
|
138
|
+
);
|
|
139
|
+
const both = countDriftDelta(delta);
|
|
140
|
+
expect(both.introduced.observedButUndocumentedEvents).toBe(1);
|
|
141
|
+
expect(both.introduced.total).toBe(1);
|
|
142
|
+
expect(both.resolved.observedButUndocumentedEvents).toBe(1);
|
|
143
|
+
expect(both.resolved.total).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
});
|