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,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractDeclaredFieldPaths } from './catalog';
|
|
3
|
+
|
|
4
|
+
describe('extractDeclaredFieldPaths', () => {
|
|
5
|
+
it('extracts scalar properties', () => {
|
|
6
|
+
expect(
|
|
7
|
+
extractDeclaredFieldPaths({
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
orderId: { type: 'string' },
|
|
11
|
+
totalCents: { type: 'integer' },
|
|
12
|
+
},
|
|
13
|
+
}),
|
|
14
|
+
).toEqual(['orderId', 'totalCents']);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('collapses array items under `[]`', () => {
|
|
18
|
+
expect(
|
|
19
|
+
extractDeclaredFieldPaths({
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
items: {
|
|
23
|
+
type: 'array',
|
|
24
|
+
items: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
sku: { type: 'string' },
|
|
28
|
+
quantity: { type: 'integer' },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
).toEqual(['items', 'items[].quantity', 'items[].sku']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('walks nested objects', () => {
|
|
38
|
+
expect(
|
|
39
|
+
extractDeclaredFieldPaths({
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
usage: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
promptTokens: { type: 'integer' },
|
|
46
|
+
completionTokens: { type: 'integer' },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
).toEqual(['usage', 'usage.completionTokens', 'usage.promptTokens']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns an empty list for non-object schemas', () => {
|
|
55
|
+
expect(extractDeclaredFieldPaths({ type: 'string' })).toEqual([]);
|
|
56
|
+
expect(extractDeclaredFieldPaths(null)).toEqual([]);
|
|
57
|
+
expect(extractDeclaredFieldPaths()).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Path classification and frontmatter parsing now live in @eventcatalog/sdk
|
|
62
|
+
// (since v2.21); the cross-platform path tests that used to live here are
|
|
63
|
+
// covered by the SDK's own test suite.
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Read the current state of an EventCatalog using @eventcatalog/sdk. We
|
|
2
|
+
// re-use SDK types (`Event`, `Service`, `Channel`) verbatim and only add the
|
|
3
|
+
// two fields the SDK doesn't expose: the resolved on-disk `filePath`, and the
|
|
4
|
+
// dotted field-path + schema-constraint extractions our drift diff consumes.
|
|
5
|
+
// Inventing our own catalog types would silently drift from the SDK as it
|
|
6
|
+
// evolves.
|
|
7
|
+
|
|
8
|
+
import utils from '@eventcatalog/sdk';
|
|
9
|
+
import type { Channel, Event, Service } from '@eventcatalog/sdk';
|
|
10
|
+
|
|
11
|
+
export type SchemaConstraint = {
|
|
12
|
+
types?: string[];
|
|
13
|
+
enumValues?: unknown[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type CatalogEvent = Event & {
|
|
17
|
+
/** Absolute path to the event's `index.mdx`, resolved via SDK. */
|
|
18
|
+
filePath: string;
|
|
19
|
+
/** Field paths declared in the event's JSON Schema (if present). */
|
|
20
|
+
declaredFieldPaths?: string[];
|
|
21
|
+
declaredSchemaConstraints?: Record<string, SchemaConstraint>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type CatalogService = Service & {
|
|
25
|
+
filePath: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CatalogChannel = Channel & {
|
|
29
|
+
filePath: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type CatalogState = {
|
|
33
|
+
events: Map<string, CatalogEvent>;
|
|
34
|
+
services: Map<string, CatalogService>;
|
|
35
|
+
channels: Map<string, CatalogChannel>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export async function readCatalogState(
|
|
39
|
+
catalogPath: string,
|
|
40
|
+
): Promise<CatalogState> {
|
|
41
|
+
const sdk = utils(catalogPath);
|
|
42
|
+
|
|
43
|
+
const [events, services, channels] = await Promise.all([
|
|
44
|
+
sdk.getEvents({ latestOnly: true, attachSchema: true }),
|
|
45
|
+
sdk.getServices({ latestOnly: true }),
|
|
46
|
+
sdk.getChannels({ latestOnly: true }),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const state: CatalogState = {
|
|
50
|
+
events: new Map(),
|
|
51
|
+
services: new Map(),
|
|
52
|
+
channels: new Map(),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// SDK quirk (as of 2.21.2): unlike the other helpers, `getResourcePath` is
|
|
56
|
+
// not curried with `catalogDir`, so we pass the catalog path explicitly.
|
|
57
|
+
const resolveFilePath = async (id: string, version?: string) => {
|
|
58
|
+
const paths = await sdk.getResourcePath(catalogPath, id, version);
|
|
59
|
+
return paths?.fullPath ?? '';
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
for (const e of events ?? []) {
|
|
63
|
+
const filePath = await resolveFilePath(e.id, e.version);
|
|
64
|
+
const schemaExtractions = e.schema
|
|
65
|
+
? {
|
|
66
|
+
declaredFieldPaths: extractDeclaredFieldPaths(e.schema),
|
|
67
|
+
declaredSchemaConstraints: extractDeclaredSchemaConstraints(e.schema),
|
|
68
|
+
}
|
|
69
|
+
: {};
|
|
70
|
+
state.events.set(e.id, { ...e, filePath, ...schemaExtractions });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const s of services ?? []) {
|
|
74
|
+
const filePath = await resolveFilePath(s.id, s.version);
|
|
75
|
+
state.services.set(s.id, { ...s, filePath });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const c of channels ?? []) {
|
|
79
|
+
const filePath = await resolveFilePath(c.id, c.version);
|
|
80
|
+
state.channels.set(c.id, { ...c, filePath });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return state;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract field paths from a JSON Schema. Mirrors the dotted-path convention
|
|
88
|
+
* used by the snapshot subscriber: arrays collapse to `[]`, nested objects
|
|
89
|
+
* use `.`. We walk `properties` (objects) and `items` (arrays).
|
|
90
|
+
*/
|
|
91
|
+
export function extractDeclaredFieldPaths(
|
|
92
|
+
schema: unknown,
|
|
93
|
+
prefix = '',
|
|
94
|
+
): string[] {
|
|
95
|
+
const out = new Set<string>();
|
|
96
|
+
walkSchema(schema, prefix, out);
|
|
97
|
+
return [...out].toSorted();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function walkSchema(schema: unknown, prefix: string, out: Set<string>): void {
|
|
101
|
+
if (!schema || typeof schema !== 'object') return;
|
|
102
|
+
const s = schema as Record<string, unknown>;
|
|
103
|
+
|
|
104
|
+
if (s.properties && typeof s.properties === 'object') {
|
|
105
|
+
for (const [key, sub] of Object.entries(
|
|
106
|
+
s.properties as Record<string, unknown>,
|
|
107
|
+
)) {
|
|
108
|
+
const path = prefix === '' ? key : `${prefix}.${key}`;
|
|
109
|
+
out.add(path);
|
|
110
|
+
walkSchema(sub, path, out);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (s.items) {
|
|
115
|
+
const arrayPrefix = prefix + '[]';
|
|
116
|
+
walkSchema(s.items, arrayPrefix, out);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function extractDeclaredSchemaConstraints(
|
|
121
|
+
schema: unknown,
|
|
122
|
+
prefix = '',
|
|
123
|
+
): Record<string, SchemaConstraint> {
|
|
124
|
+
const out = new Map<string, SchemaConstraint>();
|
|
125
|
+
walkSchemaConstraints(schema, prefix, out);
|
|
126
|
+
const obj: Record<string, SchemaConstraint> = {};
|
|
127
|
+
for (const [path, c] of out) obj[path] = c;
|
|
128
|
+
return obj;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function walkSchemaConstraints(
|
|
132
|
+
schema: unknown,
|
|
133
|
+
prefix: string,
|
|
134
|
+
out: Map<string, SchemaConstraint>,
|
|
135
|
+
): void {
|
|
136
|
+
if (!schema || typeof schema !== 'object') return;
|
|
137
|
+
const s = schema as Record<string, unknown>;
|
|
138
|
+
const typeVal = s.type;
|
|
139
|
+
const enumVal = s.enum;
|
|
140
|
+
if (prefix !== '' && (typeVal !== undefined || enumVal !== undefined)) {
|
|
141
|
+
const types = toTypeArray(typeVal);
|
|
142
|
+
const enumValues = Array.isArray(enumVal) ? [...enumVal] : undefined;
|
|
143
|
+
out.set(prefix, {
|
|
144
|
+
...(types.length > 0 ? { types } : {}),
|
|
145
|
+
...(enumValues ? { enumValues } : {}),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (s.properties && typeof s.properties === 'object') {
|
|
150
|
+
for (const [key, sub] of Object.entries(
|
|
151
|
+
s.properties as Record<string, unknown>,
|
|
152
|
+
)) {
|
|
153
|
+
const path = prefix === '' ? key : `${prefix}.${key}`;
|
|
154
|
+
walkSchemaConstraints(sub, path, out);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (s.items) {
|
|
158
|
+
const arrayPrefix = prefix + '[]';
|
|
159
|
+
walkSchemaConstraints(s.items, arrayPrefix, out);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function toTypeArray(typeVal: unknown): string[] {
|
|
164
|
+
if (typeof typeVal === 'string') return [typeVal];
|
|
165
|
+
if (Array.isArray(typeVal)) {
|
|
166
|
+
return typeVal.filter((t): t is string => typeof t === 'string').toSorted();
|
|
167
|
+
}
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// End-to-end tests for the CLI dispatcher. These run the compiled
|
|
2
|
+
// dist/cli.js as a subprocess against fixture inputs — the same shape a
|
|
3
|
+
// CI step or a user shell would invoke.
|
|
4
|
+
//
|
|
5
|
+
// Unit tests in cli.ts would test each branch in isolation; these tests
|
|
6
|
+
// verify the wiring between branches and exit codes. They catch the bug
|
|
7
|
+
// class fixed in PR ("the action invoked the CLI without --fail-on-drift,
|
|
8
|
+
// so exit code stayed 0 regardless of drift").
|
|
9
|
+
//
|
|
10
|
+
// Requires `dist/cli.js` to exist (run `pnpm build` first). The
|
|
11
|
+
// `pnpm test:e2e` script does this for you.
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import {
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdtempSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
} from 'node:fs';
|
|
22
|
+
import { join, dirname } from 'node:path';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { ARCHITECTURE_SNAPSHOT_SPEC } from 'autotel-subscribers/architecture-snapshot';
|
|
26
|
+
|
|
27
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const CLI = join(HERE, '..', 'dist', 'cli.js');
|
|
29
|
+
|
|
30
|
+
type CliResult = {
|
|
31
|
+
exitCode: number;
|
|
32
|
+
stdout: string;
|
|
33
|
+
stderr: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function runCli(args: string[]): CliResult {
|
|
37
|
+
const result = spawnSync('node', [CLI, ...args], { encoding: 'utf8' });
|
|
38
|
+
return {
|
|
39
|
+
exitCode: result.status ?? -1,
|
|
40
|
+
stdout: result.stdout ?? '',
|
|
41
|
+
stderr: result.stderr ?? '',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Tiny fixture builder — a snapshot file + a minimal catalog tree. */
|
|
46
|
+
function buildFixture(opts: {
|
|
47
|
+
/** Snapshot events keyed by event name. */
|
|
48
|
+
events: Record<
|
|
49
|
+
string,
|
|
50
|
+
{
|
|
51
|
+
fields: string[];
|
|
52
|
+
producer?: string;
|
|
53
|
+
channel?: string;
|
|
54
|
+
}
|
|
55
|
+
>;
|
|
56
|
+
/** Catalog events to write as <root>/events/<id>/index.mdx files. */
|
|
57
|
+
catalogEvents: Array<{ id: string; declaredFields?: string[] }>;
|
|
58
|
+
}): { snapshotPath: string; catalogPath: string; root: string } {
|
|
59
|
+
const root = mkdtempSync(join(tmpdir(), 'autotel-cli-e2e-'));
|
|
60
|
+
const catalogPath = join(root, 'catalog');
|
|
61
|
+
mkdirSync(catalogPath, { recursive: true });
|
|
62
|
+
|
|
63
|
+
const snapshot = {
|
|
64
|
+
spec: ARCHITECTURE_SNAPSHOT_SPEC,
|
|
65
|
+
generatedAt: '2026-05-22T00:00:00.000Z',
|
|
66
|
+
service: 'fixture',
|
|
67
|
+
events: Object.fromEntries(
|
|
68
|
+
Object.entries(opts.events).map(([name, e]) => [
|
|
69
|
+
name,
|
|
70
|
+
{
|
|
71
|
+
name,
|
|
72
|
+
observedCount: 3,
|
|
73
|
+
firstSeen: '2026-05-22T00:00:00.000Z',
|
|
74
|
+
lastSeen: '2026-05-22T00:00:00.000Z',
|
|
75
|
+
fieldPaths: e.fields,
|
|
76
|
+
sampleTraceIds: [],
|
|
77
|
+
...(e.producer ? { producer: e.producer } : {}),
|
|
78
|
+
...(e.channel ? { channel: e.channel } : {}),
|
|
79
|
+
},
|
|
80
|
+
]),
|
|
81
|
+
),
|
|
82
|
+
};
|
|
83
|
+
const snapshotPath = join(root, 'snapshot.json');
|
|
84
|
+
writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
|
|
85
|
+
|
|
86
|
+
for (const ev of opts.catalogEvents) {
|
|
87
|
+
const dir = join(catalogPath, 'events', ev.id);
|
|
88
|
+
mkdirSync(dir, { recursive: true });
|
|
89
|
+
const frontmatter = ev.declaredFields
|
|
90
|
+
? `---\nid: ${ev.id}\nversion: 1.0.0\nschemaPath: schema.json\n---\n`
|
|
91
|
+
: `---\nid: ${ev.id}\nversion: 1.0.0\n---\n`;
|
|
92
|
+
writeFileSync(join(dir, 'index.mdx'), frontmatter + '\n## Overview\n');
|
|
93
|
+
if (ev.declaredFields) {
|
|
94
|
+
const schema = {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: Object.fromEntries(
|
|
97
|
+
ev.declaredFields.map((f) => [f, { type: 'string' }]),
|
|
98
|
+
),
|
|
99
|
+
};
|
|
100
|
+
writeFileSync(join(dir, 'schema.json'), JSON.stringify(schema, null, 2));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { snapshotPath, catalogPath, root };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
beforeAll(() => {
|
|
108
|
+
if (!existsSync(CLI)) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`CLI not built: ${CLI}\nRun \`pnpm build\` before \`pnpm test:e2e\`.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('cli e2e — drift', () => {
|
|
116
|
+
it('exits 0 with --fail-on-drift when catalog and runtime agree', () => {
|
|
117
|
+
const { snapshotPath, catalogPath } = buildFixture({
|
|
118
|
+
events: { 'order.placed': { fields: ['orderId'] } },
|
|
119
|
+
catalogEvents: [{ id: 'OrderPlaced', declaredFields: ['orderId'] }],
|
|
120
|
+
});
|
|
121
|
+
const res = runCli([
|
|
122
|
+
'drift',
|
|
123
|
+
'--snapshot',
|
|
124
|
+
snapshotPath,
|
|
125
|
+
'--catalog',
|
|
126
|
+
catalogPath,
|
|
127
|
+
'--fail-on-drift',
|
|
128
|
+
]);
|
|
129
|
+
expect(res.exitCode).toBe(0);
|
|
130
|
+
expect(res.stdout).toContain('No drift detected');
|
|
131
|
+
expect(res.stderr).toContain('No drift detected');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('exits 1 with --fail-on-drift when drift exists', () => {
|
|
135
|
+
const { snapshotPath, catalogPath } = buildFixture({
|
|
136
|
+
events: { 'order.cancelled': { fields: ['orderId'] } },
|
|
137
|
+
catalogEvents: [{ id: 'OrderPlaced' }],
|
|
138
|
+
});
|
|
139
|
+
const res = runCli([
|
|
140
|
+
'drift',
|
|
141
|
+
'--snapshot',
|
|
142
|
+
snapshotPath,
|
|
143
|
+
'--catalog',
|
|
144
|
+
catalogPath,
|
|
145
|
+
'--fail-on-drift',
|
|
146
|
+
]);
|
|
147
|
+
expect(res.exitCode).toBe(1);
|
|
148
|
+
expect(res.stdout).toContain('Architecture drift report');
|
|
149
|
+
expect(res.stderr).toMatch(/Drift detected/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('exits 0 WITHOUT --fail-on-drift even when drift exists', () => {
|
|
153
|
+
const { snapshotPath, catalogPath } = buildFixture({
|
|
154
|
+
events: { 'order.cancelled': { fields: ['orderId'] } },
|
|
155
|
+
catalogEvents: [{ id: 'OrderPlaced' }],
|
|
156
|
+
});
|
|
157
|
+
const res = runCli([
|
|
158
|
+
'drift',
|
|
159
|
+
'--snapshot',
|
|
160
|
+
snapshotPath,
|
|
161
|
+
'--catalog',
|
|
162
|
+
catalogPath,
|
|
163
|
+
]);
|
|
164
|
+
expect(res.exitCode).toBe(0);
|
|
165
|
+
expect(res.stdout).toContain('Architecture drift report');
|
|
166
|
+
// Drift outcome line is still printed to stderr — the gating just doesn't fire.
|
|
167
|
+
expect(res.stderr).toMatch(/Drift detected/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('exits 2 when --policy new-only is set without --base-snapshot', () => {
|
|
171
|
+
const { snapshotPath, catalogPath } = buildFixture({
|
|
172
|
+
events: { 'order.placed': { fields: ['orderId'] } },
|
|
173
|
+
catalogEvents: [{ id: 'OrderPlaced' }],
|
|
174
|
+
});
|
|
175
|
+
const res = runCli([
|
|
176
|
+
'drift',
|
|
177
|
+
'--snapshot',
|
|
178
|
+
snapshotPath,
|
|
179
|
+
'--catalog',
|
|
180
|
+
catalogPath,
|
|
181
|
+
'--policy',
|
|
182
|
+
'new-only',
|
|
183
|
+
]);
|
|
184
|
+
expect(res.exitCode).toBe(2);
|
|
185
|
+
expect(res.stderr).toContain('--policy new-only requires --base-snapshot');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('outputs versioned JSON when --format json is set', () => {
|
|
189
|
+
const { snapshotPath, catalogPath } = buildFixture({
|
|
190
|
+
events: { 'order.placed': { fields: ['orderId'] } },
|
|
191
|
+
catalogEvents: [{ id: 'OrderPlaced' }],
|
|
192
|
+
});
|
|
193
|
+
const res = runCli([
|
|
194
|
+
'drift',
|
|
195
|
+
'--snapshot',
|
|
196
|
+
snapshotPath,
|
|
197
|
+
'--catalog',
|
|
198
|
+
catalogPath,
|
|
199
|
+
'--format',
|
|
200
|
+
'json',
|
|
201
|
+
]);
|
|
202
|
+
expect(res.exitCode).toBe(0);
|
|
203
|
+
const parsed = JSON.parse(res.stdout);
|
|
204
|
+
expect(parsed.spec).toBe('autotel-eventcatalog-report/v0.2.0');
|
|
205
|
+
expect(parsed.mode).toBe('all');
|
|
206
|
+
expect(parsed.report.snapshotService).toBe('fixture');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('errors clearly when given an unknown flag', () => {
|
|
210
|
+
const { snapshotPath, catalogPath } = buildFixture({
|
|
211
|
+
events: { 'order.placed': { fields: ['orderId'] } },
|
|
212
|
+
catalogEvents: [{ id: 'OrderPlaced' }],
|
|
213
|
+
});
|
|
214
|
+
const res = runCli([
|
|
215
|
+
'drift',
|
|
216
|
+
'--snapshot',
|
|
217
|
+
snapshotPath,
|
|
218
|
+
'--catalog',
|
|
219
|
+
catalogPath,
|
|
220
|
+
'--made-up-flag',
|
|
221
|
+
]);
|
|
222
|
+
expect(res.exitCode).toBe(2);
|
|
223
|
+
expect(res.stderr).toContain('Unknown argument');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('cli e2e — stamp', () => {
|
|
228
|
+
it('dry-run leaves files untouched', () => {
|
|
229
|
+
const { snapshotPath, catalogPath } = buildFixture({
|
|
230
|
+
events: { 'order.placed': { fields: ['orderId'] } },
|
|
231
|
+
catalogEvents: [{ id: 'OrderPlaced' }],
|
|
232
|
+
});
|
|
233
|
+
const res = runCli([
|
|
234
|
+
'stamp',
|
|
235
|
+
'--snapshot',
|
|
236
|
+
snapshotPath,
|
|
237
|
+
'--catalog',
|
|
238
|
+
catalogPath,
|
|
239
|
+
'--dry-run',
|
|
240
|
+
]);
|
|
241
|
+
expect(res.exitCode).toBe(0);
|
|
242
|
+
expect(res.stdout).toMatch(/would insert OrderPlaced/);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('writes a versioned summary JSON when --summary-output is given', () => {
|
|
246
|
+
const { snapshotPath, catalogPath, root } = buildFixture({
|
|
247
|
+
events: { 'order.placed': { fields: ['orderId'] } },
|
|
248
|
+
catalogEvents: [{ id: 'OrderPlaced' }],
|
|
249
|
+
});
|
|
250
|
+
const summaryPath = join(root, 'stamp-summary.json');
|
|
251
|
+
|
|
252
|
+
const res = runCli([
|
|
253
|
+
'stamp',
|
|
254
|
+
'--snapshot',
|
|
255
|
+
snapshotPath,
|
|
256
|
+
'--catalog',
|
|
257
|
+
catalogPath,
|
|
258
|
+
'--summary-output',
|
|
259
|
+
summaryPath,
|
|
260
|
+
]);
|
|
261
|
+
expect(res.exitCode).toBe(0);
|
|
262
|
+
|
|
263
|
+
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
|
|
264
|
+
expect(summary.spec).toBe('autotel-eventcatalog-stamp-summary/v0.1.0');
|
|
265
|
+
expect(summary.hadChanges).toBe(true);
|
|
266
|
+
expect(summary.inserts).toBe(1);
|
|
267
|
+
expect(summary.changedFiles).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('summary.hadChanges is false when re-stamping is a no-op', () => {
|
|
271
|
+
const { snapshotPath, catalogPath, root } = buildFixture({
|
|
272
|
+
events: { 'order.placed': { fields: ['orderId'] } },
|
|
273
|
+
catalogEvents: [{ id: 'OrderPlaced' }],
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// First stamp writes the block.
|
|
277
|
+
runCli(['stamp', '--snapshot', snapshotPath, '--catalog', catalogPath]);
|
|
278
|
+
|
|
279
|
+
// Second stamp with identical input is a no-op.
|
|
280
|
+
const summaryPath = join(root, 'stamp-summary-2.json');
|
|
281
|
+
const res = runCli([
|
|
282
|
+
'stamp',
|
|
283
|
+
'--snapshot',
|
|
284
|
+
snapshotPath,
|
|
285
|
+
'--catalog',
|
|
286
|
+
catalogPath,
|
|
287
|
+
'--summary-output',
|
|
288
|
+
summaryPath,
|
|
289
|
+
]);
|
|
290
|
+
expect(res.exitCode).toBe(0);
|
|
291
|
+
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
|
|
292
|
+
expect(summary.hadChanges).toBe(false);
|
|
293
|
+
expect(summary.changedFiles).toBe(0);
|
|
294
|
+
expect(summary.replaces).toBe(1);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('cli e2e — top-level', () => {
|
|
299
|
+
it('exits 2 with usage on no command', () => {
|
|
300
|
+
const res = runCli([]);
|
|
301
|
+
expect(res.exitCode).toBe(2);
|
|
302
|
+
expect(res.stderr).toContain('Usage');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('exits 2 on unknown command', () => {
|
|
306
|
+
const res = runCli(['rumpus']);
|
|
307
|
+
expect(res.exitCode).toBe(2);
|
|
308
|
+
expect(res.stderr).toContain('Usage');
|
|
309
|
+
});
|
|
310
|
+
});
|