@curl-runner/cli 1.10.0 → 1.12.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.
@@ -0,0 +1,296 @@
1
+ import type { JsonValue, Snapshot, SnapshotConfig, SnapshotDiff } from '../types/config';
2
+
3
+ interface DiffResult {
4
+ match: boolean;
5
+ differences: SnapshotDiff[];
6
+ }
7
+
8
+ /**
9
+ * Compares snapshots with support for exclusions, wildcards, and regex patterns.
10
+ */
11
+ export class SnapshotDiffer {
12
+ private excludePaths: Set<string>;
13
+ private matchRules: Map<string, string>;
14
+
15
+ constructor(config: SnapshotConfig) {
16
+ this.excludePaths = new Set(config.exclude || []);
17
+ this.matchRules = new Map(Object.entries(config.match || {}));
18
+ }
19
+
20
+ /**
21
+ * Compares two snapshots and returns differences.
22
+ */
23
+ compare(expected: Snapshot, received: Snapshot): DiffResult {
24
+ const differences: SnapshotDiff[] = [];
25
+
26
+ // Compare status
27
+ if (expected.status !== undefined || received.status !== undefined) {
28
+ if (expected.status !== received.status && !this.isExcluded('status')) {
29
+ differences.push({
30
+ path: 'status',
31
+ expected: expected.status,
32
+ received: received.status,
33
+ type: 'changed',
34
+ });
35
+ }
36
+ }
37
+
38
+ // Compare headers
39
+ if (expected.headers || received.headers) {
40
+ const headerDiffs = this.compareObjects(
41
+ expected.headers || {},
42
+ received.headers || {},
43
+ 'headers',
44
+ );
45
+ differences.push(...headerDiffs);
46
+ }
47
+
48
+ // Compare body
49
+ if (expected.body !== undefined || received.body !== undefined) {
50
+ const bodyDiffs = this.deepCompare(expected.body, received.body, 'body');
51
+ differences.push(...bodyDiffs);
52
+ }
53
+
54
+ return {
55
+ match: differences.length === 0,
56
+ differences,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Deep comparison of two values with path tracking.
62
+ */
63
+ deepCompare(expected: unknown, received: unknown, path: string): SnapshotDiff[] {
64
+ // Check if path is excluded
65
+ if (this.isExcluded(path)) {
66
+ return [];
67
+ }
68
+
69
+ // Check match rules (wildcards, regex)
70
+ if (this.matchesRule(path, received)) {
71
+ return [];
72
+ }
73
+
74
+ // Both null/undefined
75
+ if (expected === null && received === null) {
76
+ return [];
77
+ }
78
+ if (expected === undefined && received === undefined) {
79
+ return [];
80
+ }
81
+
82
+ // Type mismatch
83
+ const expectedType = this.getType(expected);
84
+ const receivedType = this.getType(received);
85
+ if (expectedType !== receivedType) {
86
+ return [
87
+ {
88
+ path,
89
+ expected,
90
+ received,
91
+ type: 'type_mismatch',
92
+ },
93
+ ];
94
+ }
95
+
96
+ // Primitives
97
+ if (expectedType !== 'object' && expectedType !== 'array') {
98
+ if (expected !== received) {
99
+ return [
100
+ {
101
+ path,
102
+ expected,
103
+ received,
104
+ type: 'changed',
105
+ },
106
+ ];
107
+ }
108
+ return [];
109
+ }
110
+
111
+ // Arrays
112
+ if (expectedType === 'array') {
113
+ return this.compareArrays(expected as JsonValue[], received as JsonValue[], path);
114
+ }
115
+
116
+ // Objects
117
+ return this.compareObjects(
118
+ expected as Record<string, unknown>,
119
+ received as Record<string, unknown>,
120
+ path,
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Compares two arrays.
126
+ */
127
+ private compareArrays(
128
+ expected: JsonValue[],
129
+ received: JsonValue[],
130
+ path: string,
131
+ ): SnapshotDiff[] {
132
+ const differences: SnapshotDiff[] = [];
133
+
134
+ // Check length difference
135
+ const maxLen = Math.max(expected.length, received.length);
136
+
137
+ for (let i = 0; i < maxLen; i++) {
138
+ const itemPath = `${path}[${i}]`;
139
+
140
+ if (i >= expected.length) {
141
+ // Added item
142
+ if (!this.isExcluded(itemPath)) {
143
+ differences.push({
144
+ path: itemPath,
145
+ expected: undefined,
146
+ received: received[i],
147
+ type: 'added',
148
+ });
149
+ }
150
+ } else if (i >= received.length) {
151
+ // Removed item
152
+ if (!this.isExcluded(itemPath)) {
153
+ differences.push({
154
+ path: itemPath,
155
+ expected: expected[i],
156
+ received: undefined,
157
+ type: 'removed',
158
+ });
159
+ }
160
+ } else {
161
+ // Compare items
162
+ const itemDiffs = this.deepCompare(expected[i], received[i], itemPath);
163
+ differences.push(...itemDiffs);
164
+ }
165
+ }
166
+
167
+ return differences;
168
+ }
169
+
170
+ /**
171
+ * Compares two objects.
172
+ */
173
+ private compareObjects(
174
+ expected: Record<string, unknown>,
175
+ received: Record<string, unknown>,
176
+ path: string,
177
+ ): SnapshotDiff[] {
178
+ const differences: SnapshotDiff[] = [];
179
+ const allKeys = new Set([...Object.keys(expected), ...Object.keys(received)]);
180
+
181
+ for (const key of allKeys) {
182
+ const keyPath = path ? `${path}.${key}` : key;
183
+ const hasExpected = key in expected;
184
+ const hasReceived = key in received;
185
+
186
+ if (!hasExpected && hasReceived) {
187
+ // Added key
188
+ if (!this.isExcluded(keyPath)) {
189
+ differences.push({
190
+ path: keyPath,
191
+ expected: undefined,
192
+ received: received[key],
193
+ type: 'added',
194
+ });
195
+ }
196
+ } else if (hasExpected && !hasReceived) {
197
+ // Removed key
198
+ if (!this.isExcluded(keyPath)) {
199
+ differences.push({
200
+ path: keyPath,
201
+ expected: expected[key],
202
+ received: undefined,
203
+ type: 'removed',
204
+ });
205
+ }
206
+ } else {
207
+ // Compare values
208
+ const keyDiffs = this.deepCompare(expected[key], received[key], keyPath);
209
+ differences.push(...keyDiffs);
210
+ }
211
+ }
212
+
213
+ return differences;
214
+ }
215
+
216
+ /**
217
+ * Checks if a path should be excluded from comparison.
218
+ */
219
+ isExcluded(path: string): boolean {
220
+ // Exact match
221
+ if (this.excludePaths.has(path)) {
222
+ return true;
223
+ }
224
+
225
+ for (const pattern of this.excludePaths) {
226
+ // Wildcard prefix (e.g., '*.timestamp')
227
+ if (pattern.startsWith('*.')) {
228
+ const suffix = pattern.slice(2);
229
+ if (path.endsWith(`.${suffix}`)) {
230
+ return true;
231
+ }
232
+ // Also match root level (e.g., 'timestamp' matches '*.timestamp')
233
+ const lastPart = path.split('.').pop();
234
+ if (lastPart === suffix) {
235
+ return true;
236
+ }
237
+ }
238
+
239
+ // Array wildcard (e.g., 'body[*].id')
240
+ if (pattern.includes('[*]')) {
241
+ const regex = new RegExp(
242
+ `^${pattern.replace(/\[\*\]/g, '\\[\\d+\\]').replace(/\./g, '\\.')}$`,
243
+ );
244
+ if (regex.test(path)) {
245
+ return true;
246
+ }
247
+ }
248
+ }
249
+
250
+ return false;
251
+ }
252
+
253
+ /**
254
+ * Checks if a value matches a custom rule for its path.
255
+ */
256
+ matchesRule(path: string, value: unknown): boolean {
257
+ const rule = this.matchRules.get(path);
258
+ if (!rule) {
259
+ return false;
260
+ }
261
+
262
+ // Wildcard: accept any value
263
+ if (rule === '*') {
264
+ return true;
265
+ }
266
+
267
+ // Regex pattern
268
+ if (rule.startsWith('regex:')) {
269
+ const pattern = rule.slice(6);
270
+ try {
271
+ const regex = new RegExp(pattern);
272
+ return regex.test(String(value));
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+
278
+ return false;
279
+ }
280
+
281
+ /**
282
+ * Gets the type of a value for comparison.
283
+ */
284
+ private getType(value: unknown): string {
285
+ if (value === null) {
286
+ return 'null';
287
+ }
288
+ if (value === undefined) {
289
+ return 'undefined';
290
+ }
291
+ if (Array.isArray(value)) {
292
+ return 'array';
293
+ }
294
+ return typeof value;
295
+ }
296
+ }
@@ -0,0 +1,170 @@
1
+ import type { SnapshotCompareResult, SnapshotDiff } from '../types/config';
2
+
3
+ const COLORS = {
4
+ reset: '\x1b[0m',
5
+ red: '\x1b[31m',
6
+ green: '\x1b[32m',
7
+ yellow: '\x1b[33m',
8
+ cyan: '\x1b[36m',
9
+ dim: '\x1b[2m',
10
+ bright: '\x1b[1m',
11
+ };
12
+
13
+ /**
14
+ * Formats snapshot comparison results for terminal output.
15
+ */
16
+ export class SnapshotFormatter {
17
+ private color(text: string, color: keyof typeof COLORS): string {
18
+ return `${COLORS[color]}${text}${COLORS.reset}`;
19
+ }
20
+
21
+ /**
22
+ * Formats the snapshot result for display.
23
+ */
24
+ formatResult(requestName: string, result: SnapshotCompareResult): string {
25
+ const lines: string[] = [];
26
+
27
+ if (result.isNew && result.updated) {
28
+ lines.push(
29
+ ` ${this.color('NEW', 'cyan')} Snapshot created for "${this.color(requestName, 'bright')}"`,
30
+ );
31
+ return lines.join('\n');
32
+ }
33
+
34
+ if (result.updated) {
35
+ lines.push(
36
+ ` ${this.color('UPDATED', 'yellow')} Snapshot updated for "${this.color(requestName, 'bright')}"`,
37
+ );
38
+ return lines.join('\n');
39
+ }
40
+
41
+ if (result.match) {
42
+ lines.push(
43
+ ` ${this.color('PASS', 'green')} Snapshot matches for "${this.color(requestName, 'bright')}"`,
44
+ );
45
+ return lines.join('\n');
46
+ }
47
+
48
+ // Mismatch
49
+ lines.push(
50
+ ` ${this.color('FAIL', 'red')} Snapshot mismatch for "${this.color(requestName, 'bright')}"`,
51
+ );
52
+ lines.push('');
53
+ lines.push(this.formatDiff(result.differences));
54
+ lines.push('');
55
+ lines.push(this.color(' Run with --update-snapshots (-u) to update', 'dim'));
56
+
57
+ return lines.join('\n');
58
+ }
59
+
60
+ /**
61
+ * Formats differences for display.
62
+ */
63
+ formatDiff(differences: SnapshotDiff[]): string {
64
+ const lines: string[] = [];
65
+ lines.push(` ${this.color('- Expected', 'red')}`);
66
+ lines.push(` ${this.color('+ Received', 'green')}`);
67
+ lines.push('');
68
+
69
+ for (const diff of differences) {
70
+ lines.push(this.formatDifference(diff));
71
+ }
72
+
73
+ return lines.join('\n');
74
+ }
75
+
76
+ /**
77
+ * Formats a single difference.
78
+ */
79
+ private formatDifference(diff: SnapshotDiff): string {
80
+ const lines: string[] = [];
81
+ const path = diff.path || '(root)';
82
+
83
+ switch (diff.type) {
84
+ case 'added':
85
+ lines.push(` ${this.color(path, 'cyan')}:`);
86
+ lines.push(` ${this.color(`+ ${this.stringify(diff.received)}`, 'green')}`);
87
+ break;
88
+
89
+ case 'removed':
90
+ lines.push(` ${this.color(path, 'cyan')}:`);
91
+ lines.push(` ${this.color(`- ${this.stringify(diff.expected)}`, 'red')}`);
92
+ break;
93
+
94
+ case 'changed':
95
+ lines.push(` ${this.color(path, 'cyan')}:`);
96
+ lines.push(` ${this.color(`- ${this.stringify(diff.expected)}`, 'red')}`);
97
+ lines.push(` ${this.color(`+ ${this.stringify(diff.received)}`, 'green')}`);
98
+ break;
99
+
100
+ case 'type_mismatch':
101
+ lines.push(` ${this.color(path, 'cyan')} (type mismatch):`);
102
+ lines.push(
103
+ ` ${this.color(`- ${this.stringify(diff.expected)} (${typeof diff.expected})`, 'red')}`,
104
+ );
105
+ lines.push(
106
+ ` ${this.color(`+ ${this.stringify(diff.received)} (${typeof diff.received})`, 'green')}`,
107
+ );
108
+ break;
109
+ }
110
+
111
+ return lines.join('\n');
112
+ }
113
+
114
+ /**
115
+ * Converts value to display string.
116
+ */
117
+ private stringify(value: unknown): string {
118
+ if (value === undefined) {
119
+ return 'undefined';
120
+ }
121
+ if (value === null) {
122
+ return 'null';
123
+ }
124
+ if (typeof value === 'string') {
125
+ return `"${value}"`;
126
+ }
127
+ if (typeof value === 'object') {
128
+ const str = JSON.stringify(value);
129
+ // Truncate long values
130
+ if (str.length > 80) {
131
+ return `${str.slice(0, 77)}...`;
132
+ }
133
+ return str;
134
+ }
135
+ return String(value);
136
+ }
137
+
138
+ /**
139
+ * Formats summary statistics.
140
+ */
141
+ formatSummary(stats: SnapshotStats): string {
142
+ const parts: string[] = [];
143
+
144
+ if (stats.passed > 0) {
145
+ parts.push(this.color(`${stats.passed} passed`, 'green'));
146
+ }
147
+ if (stats.failed > 0) {
148
+ parts.push(this.color(`${stats.failed} failed`, 'red'));
149
+ }
150
+ if (stats.updated > 0) {
151
+ parts.push(this.color(`${stats.updated} updated`, 'yellow'));
152
+ }
153
+ if (stats.created > 0) {
154
+ parts.push(this.color(`${stats.created} created`, 'cyan'));
155
+ }
156
+
157
+ if (parts.length === 0) {
158
+ return '';
159
+ }
160
+
161
+ return `Snapshots: ${parts.join(', ')}`;
162
+ }
163
+ }
164
+
165
+ export interface SnapshotStats {
166
+ passed: number;
167
+ failed: number;
168
+ updated: number;
169
+ created: number;
170
+ }
@@ -0,0 +1,204 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ExecutionResult } from '../types/config';
3
+ import { filterSnapshotBody, SnapshotManager } from './snapshot-manager';
4
+
5
+ describe('SnapshotManager', () => {
6
+ describe('getSnapshotPath', () => {
7
+ test('should generate correct snapshot path', () => {
8
+ const manager = new SnapshotManager({});
9
+ expect(manager.getSnapshotPath('tests/api.yaml')).toBe('tests/__snapshots__/api.snap.json');
10
+ });
11
+
12
+ test('should use custom directory', () => {
13
+ const manager = new SnapshotManager({ dir: '.snapshots' });
14
+ expect(manager.getSnapshotPath('api.yaml')).toBe('.snapshots/api.snap.json');
15
+ });
16
+
17
+ test('should handle nested paths', () => {
18
+ const manager = new SnapshotManager({});
19
+ expect(manager.getSnapshotPath('tests/integration/users.yaml')).toBe(
20
+ 'tests/integration/__snapshots__/users.snap.json',
21
+ );
22
+ });
23
+ });
24
+
25
+ describe('createSnapshot', () => {
26
+ const mockResult: ExecutionResult = {
27
+ request: { url: 'https://api.example.com' },
28
+ success: true,
29
+ status: 200,
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ 'X-Request-Id': 'abc123',
33
+ },
34
+ body: {
35
+ id: 1,
36
+ name: 'Test',
37
+ timestamp: '2024-01-01',
38
+ },
39
+ };
40
+
41
+ test('should create snapshot with body only by default', () => {
42
+ const manager = new SnapshotManager({});
43
+ const snapshot = manager.createSnapshot(mockResult, { include: ['body'] });
44
+
45
+ expect(snapshot.body).toEqual(mockResult.body);
46
+ expect(snapshot.status).toBeUndefined();
47
+ expect(snapshot.headers).toBeUndefined();
48
+ expect(snapshot.hash).toBeDefined();
49
+ expect(snapshot.updatedAt).toBeDefined();
50
+ });
51
+
52
+ test('should create snapshot with status', () => {
53
+ const manager = new SnapshotManager({});
54
+ const snapshot = manager.createSnapshot(mockResult, { include: ['status'] });
55
+
56
+ expect(snapshot.status).toBe(200);
57
+ expect(snapshot.body).toBeUndefined();
58
+ });
59
+
60
+ test('should create snapshot with headers (normalized)', () => {
61
+ const manager = new SnapshotManager({});
62
+ const snapshot = manager.createSnapshot(mockResult, { include: ['headers'] });
63
+
64
+ expect(snapshot.headers).toEqual({
65
+ 'content-type': 'application/json',
66
+ 'x-request-id': 'abc123',
67
+ });
68
+ });
69
+
70
+ test('should create snapshot with all components', () => {
71
+ const manager = new SnapshotManager({});
72
+ const snapshot = manager.createSnapshot(mockResult, {
73
+ include: ['status', 'headers', 'body'],
74
+ });
75
+
76
+ expect(snapshot.status).toBe(200);
77
+ expect(snapshot.headers).toBeDefined();
78
+ expect(snapshot.body).toBeDefined();
79
+ });
80
+ });
81
+
82
+ describe('hash', () => {
83
+ test('should generate consistent hashes', () => {
84
+ const manager = new SnapshotManager({});
85
+ const content = { id: 1, name: 'test' };
86
+
87
+ const hash1 = manager.hash(content);
88
+ const hash2 = manager.hash(content);
89
+
90
+ expect(hash1).toBe(hash2);
91
+ });
92
+
93
+ test('should generate different hashes for different content', () => {
94
+ const manager = new SnapshotManager({});
95
+
96
+ const hash1 = manager.hash({ id: 1 });
97
+ const hash2 = manager.hash({ id: 2 });
98
+
99
+ expect(hash1).not.toBe(hash2);
100
+ });
101
+ });
102
+
103
+ describe('mergeConfig', () => {
104
+ test('should return null if not enabled', () => {
105
+ const config = SnapshotManager.mergeConfig({}, undefined);
106
+ expect(config).toBeNull();
107
+ });
108
+
109
+ test('should handle boolean true', () => {
110
+ const config = SnapshotManager.mergeConfig({}, true);
111
+ expect(config).toEqual({
112
+ enabled: true,
113
+ include: ['body'],
114
+ exclude: [],
115
+ match: {},
116
+ });
117
+ });
118
+
119
+ test('should merge global and request excludes', () => {
120
+ const config = SnapshotManager.mergeConfig(
121
+ { exclude: ['*.timestamp'] },
122
+ { enabled: true, exclude: ['body.id'] },
123
+ );
124
+ expect(config?.exclude).toEqual(['*.timestamp', 'body.id']);
125
+ });
126
+
127
+ test('should override include from request config', () => {
128
+ const config = SnapshotManager.mergeConfig(
129
+ { include: ['body'] },
130
+ { enabled: true, include: ['status', 'body'] },
131
+ );
132
+ expect(config?.include).toEqual(['status', 'body']);
133
+ });
134
+
135
+ test('should use global enabled', () => {
136
+ const config = SnapshotManager.mergeConfig({ enabled: true }, undefined);
137
+ expect(config?.enabled).toBe(true);
138
+ });
139
+ });
140
+ });
141
+
142
+ describe('filterSnapshotBody', () => {
143
+ test('should return primitive values unchanged', () => {
144
+ expect(filterSnapshotBody('string', [])).toBe('string');
145
+ expect(filterSnapshotBody(123, [])).toBe(123);
146
+ expect(filterSnapshotBody(null, [])).toBe(null);
147
+ });
148
+
149
+ test('should return body unchanged if no excludes', () => {
150
+ const body = { id: 1, name: 'test' };
151
+ expect(filterSnapshotBody(body, [])).toEqual(body);
152
+ });
153
+
154
+ test('should filter exact paths', () => {
155
+ const body = { id: 1, timestamp: '2024-01-01', name: 'test' };
156
+ const result = filterSnapshotBody(body, ['body.timestamp']);
157
+ expect(result).toEqual({ id: 1, name: 'test' });
158
+ });
159
+
160
+ test('should filter wildcard paths', () => {
161
+ const body = {
162
+ user: { id: 1, createdAt: '2024-01-01' },
163
+ post: { id: 2, createdAt: '2024-01-02' },
164
+ };
165
+ const result = filterSnapshotBody(body, ['body.*.createdAt']);
166
+ expect(result).toEqual({
167
+ user: { id: 1 },
168
+ post: { id: 2 },
169
+ });
170
+ });
171
+
172
+ test('should filter array wildcards', () => {
173
+ const body = {
174
+ items: [
175
+ { id: 1, updatedAt: '2024-01-01' },
176
+ { id: 2, updatedAt: '2024-01-02' },
177
+ ],
178
+ };
179
+ const result = filterSnapshotBody(body, ['body.items[*].updatedAt']);
180
+ expect(result).toEqual({
181
+ items: [{ id: 1 }, { id: 2 }],
182
+ });
183
+ });
184
+
185
+ test('should handle nested objects', () => {
186
+ const body = {
187
+ data: {
188
+ user: {
189
+ id: 1,
190
+ meta: { lastLogin: '2024-01-01' },
191
+ },
192
+ },
193
+ };
194
+ const result = filterSnapshotBody(body, ['body.data.user.meta.lastLogin']);
195
+ expect(result).toEqual({
196
+ data: {
197
+ user: {
198
+ id: 1,
199
+ meta: {},
200
+ },
201
+ },
202
+ });
203
+ });
204
+ });