@curl-runner/cli 1.9.0 → 1.11.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,358 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { Snapshot } from '../types/config';
3
+ import { SnapshotDiffer } from './snapshot-differ';
4
+
5
+ describe('SnapshotDiffer', () => {
6
+ describe('basic comparison', () => {
7
+ test('should match identical snapshots', () => {
8
+ const differ = new SnapshotDiffer({});
9
+ const snapshot: Snapshot = {
10
+ status: 200,
11
+ body: { id: 1, name: 'test' },
12
+ hash: 'abc123',
13
+ updatedAt: '2024-01-01',
14
+ };
15
+
16
+ const result = differ.compare(snapshot, snapshot);
17
+ expect(result.match).toBe(true);
18
+ expect(result.differences).toHaveLength(0);
19
+ });
20
+
21
+ test('should detect status changes', () => {
22
+ const differ = new SnapshotDiffer({});
23
+ const expected: Snapshot = { status: 200, hash: 'a', updatedAt: '' };
24
+ const received: Snapshot = { status: 201, hash: 'b', updatedAt: '' };
25
+
26
+ const result = differ.compare(expected, received);
27
+ expect(result.match).toBe(false);
28
+ expect(result.differences).toHaveLength(1);
29
+ expect(result.differences[0]).toEqual({
30
+ path: 'status',
31
+ expected: 200,
32
+ received: 201,
33
+ type: 'changed',
34
+ });
35
+ });
36
+
37
+ test('should detect body value changes', () => {
38
+ const differ = new SnapshotDiffer({});
39
+ const expected: Snapshot = {
40
+ body: { name: 'old' },
41
+ hash: 'a',
42
+ updatedAt: '',
43
+ };
44
+ const received: Snapshot = {
45
+ body: { name: 'new' },
46
+ hash: 'b',
47
+ updatedAt: '',
48
+ };
49
+
50
+ const result = differ.compare(expected, received);
51
+ expect(result.match).toBe(false);
52
+ expect(result.differences[0].path).toBe('body.name');
53
+ expect(result.differences[0].type).toBe('changed');
54
+ });
55
+
56
+ test('should detect added fields', () => {
57
+ const differ = new SnapshotDiffer({});
58
+ const expected: Snapshot = {
59
+ body: { id: 1 },
60
+ hash: 'a',
61
+ updatedAt: '',
62
+ };
63
+ const received: Snapshot = {
64
+ body: { id: 1, newField: 'value' },
65
+ hash: 'b',
66
+ updatedAt: '',
67
+ };
68
+
69
+ const result = differ.compare(expected, received);
70
+ expect(result.match).toBe(false);
71
+ expect(result.differences[0].path).toBe('body.newField');
72
+ expect(result.differences[0].type).toBe('added');
73
+ });
74
+
75
+ test('should detect removed fields', () => {
76
+ const differ = new SnapshotDiffer({});
77
+ const expected: Snapshot = {
78
+ body: { id: 1, oldField: 'value' },
79
+ hash: 'a',
80
+ updatedAt: '',
81
+ };
82
+ const received: Snapshot = {
83
+ body: { id: 1 },
84
+ hash: 'b',
85
+ updatedAt: '',
86
+ };
87
+
88
+ const result = differ.compare(expected, received);
89
+ expect(result.match).toBe(false);
90
+ expect(result.differences[0].path).toBe('body.oldField');
91
+ expect(result.differences[0].type).toBe('removed');
92
+ });
93
+ });
94
+
95
+ describe('array comparison', () => {
96
+ test('should compare array elements', () => {
97
+ const differ = new SnapshotDiffer({});
98
+ const expected: Snapshot = {
99
+ body: { items: [1, 2, 3] },
100
+ hash: 'a',
101
+ updatedAt: '',
102
+ };
103
+ const received: Snapshot = {
104
+ body: { items: [1, 2, 4] },
105
+ hash: 'b',
106
+ updatedAt: '',
107
+ };
108
+
109
+ const result = differ.compare(expected, received);
110
+ expect(result.match).toBe(false);
111
+ expect(result.differences[0].path).toBe('body.items[2]');
112
+ });
113
+
114
+ test('should detect added array elements', () => {
115
+ const differ = new SnapshotDiffer({});
116
+ const expected: Snapshot = {
117
+ body: { items: [1, 2] },
118
+ hash: 'a',
119
+ updatedAt: '',
120
+ };
121
+ const received: Snapshot = {
122
+ body: { items: [1, 2, 3] },
123
+ hash: 'b',
124
+ updatedAt: '',
125
+ };
126
+
127
+ const result = differ.compare(expected, received);
128
+ expect(result.match).toBe(false);
129
+ expect(result.differences[0].path).toBe('body.items[2]');
130
+ expect(result.differences[0].type).toBe('added');
131
+ });
132
+ });
133
+
134
+ describe('exclusions', () => {
135
+ test('should exclude exact paths', () => {
136
+ const differ = new SnapshotDiffer({
137
+ exclude: ['body.timestamp'],
138
+ });
139
+ const expected: Snapshot = {
140
+ body: { id: 1, timestamp: '2024-01-01' },
141
+ hash: 'a',
142
+ updatedAt: '',
143
+ };
144
+ const received: Snapshot = {
145
+ body: { id: 1, timestamp: '2024-12-31' },
146
+ hash: 'b',
147
+ updatedAt: '',
148
+ };
149
+
150
+ const result = differ.compare(expected, received);
151
+ expect(result.match).toBe(true);
152
+ });
153
+
154
+ test('should exclude wildcard paths (*.field)', () => {
155
+ const differ = new SnapshotDiffer({
156
+ exclude: ['*.createdAt'],
157
+ });
158
+ const expected: Snapshot = {
159
+ body: { user: { createdAt: '2024-01-01' }, post: { createdAt: '2024-01-01' } },
160
+ hash: 'a',
161
+ updatedAt: '',
162
+ };
163
+ const received: Snapshot = {
164
+ body: { user: { createdAt: '2024-12-31' }, post: { createdAt: '2024-12-31' } },
165
+ hash: 'b',
166
+ updatedAt: '',
167
+ };
168
+
169
+ const result = differ.compare(expected, received);
170
+ expect(result.match).toBe(true);
171
+ });
172
+
173
+ test('should exclude array wildcard paths (body[*].id)', () => {
174
+ const differ = new SnapshotDiffer({
175
+ exclude: ['body.items[*].id'],
176
+ });
177
+ const expected: Snapshot = {
178
+ body: {
179
+ items: [
180
+ { id: 1, name: 'a' },
181
+ { id: 2, name: 'b' },
182
+ ],
183
+ },
184
+ hash: 'a',
185
+ updatedAt: '',
186
+ };
187
+ const received: Snapshot = {
188
+ body: {
189
+ items: [
190
+ { id: 99, name: 'a' },
191
+ { id: 100, name: 'b' },
192
+ ],
193
+ },
194
+ hash: 'b',
195
+ updatedAt: '',
196
+ };
197
+
198
+ const result = differ.compare(expected, received);
199
+ expect(result.match).toBe(true);
200
+ });
201
+ });
202
+
203
+ describe('match rules', () => {
204
+ test('should accept any value with wildcard (*)', () => {
205
+ const differ = new SnapshotDiffer({
206
+ match: { 'body.id': '*' },
207
+ });
208
+ const expected: Snapshot = {
209
+ body: { id: 1 },
210
+ hash: 'a',
211
+ updatedAt: '',
212
+ };
213
+ const received: Snapshot = {
214
+ body: { id: 999 },
215
+ hash: 'b',
216
+ updatedAt: '',
217
+ };
218
+
219
+ const result = differ.compare(expected, received);
220
+ expect(result.match).toBe(true);
221
+ });
222
+
223
+ test('should match regex patterns', () => {
224
+ const differ = new SnapshotDiffer({
225
+ match: { 'body.version': 'regex:^v\\d+\\.\\d+' },
226
+ });
227
+ const expected: Snapshot = {
228
+ body: { version: 'v1.0.0' },
229
+ hash: 'a',
230
+ updatedAt: '',
231
+ };
232
+ const received: Snapshot = {
233
+ body: { version: 'v2.5.3' },
234
+ hash: 'b',
235
+ updatedAt: '',
236
+ };
237
+
238
+ const result = differ.compare(expected, received);
239
+ expect(result.match).toBe(true);
240
+ });
241
+
242
+ test('should fail on non-matching regex', () => {
243
+ const differ = new SnapshotDiffer({
244
+ match: { 'body.version': 'regex:^v\\d+\\.\\d+' },
245
+ });
246
+ const expected: Snapshot = {
247
+ body: { version: 'v1.0' },
248
+ hash: 'a',
249
+ updatedAt: '',
250
+ };
251
+ const received: Snapshot = {
252
+ body: { version: 'invalid' },
253
+ hash: 'b',
254
+ updatedAt: '',
255
+ };
256
+
257
+ const result = differ.compare(expected, received);
258
+ expect(result.match).toBe(false);
259
+ });
260
+ });
261
+
262
+ describe('type mismatches', () => {
263
+ test('should detect type changes', () => {
264
+ const differ = new SnapshotDiffer({});
265
+ const expected: Snapshot = {
266
+ body: { count: 42 },
267
+ hash: 'a',
268
+ updatedAt: '',
269
+ };
270
+ const received: Snapshot = {
271
+ body: { count: '42' },
272
+ hash: 'b',
273
+ updatedAt: '',
274
+ };
275
+
276
+ const result = differ.compare(expected, received);
277
+ expect(result.match).toBe(false);
278
+ expect(result.differences[0].type).toBe('type_mismatch');
279
+ });
280
+
281
+ test('should detect object to array change', () => {
282
+ const differ = new SnapshotDiffer({});
283
+ const expected: Snapshot = {
284
+ body: { data: { key: 'value' } },
285
+ hash: 'a',
286
+ updatedAt: '',
287
+ };
288
+ const received: Snapshot = {
289
+ body: { data: ['value'] },
290
+ hash: 'b',
291
+ updatedAt: '',
292
+ };
293
+
294
+ const result = differ.compare(expected, received);
295
+ expect(result.match).toBe(false);
296
+ expect(result.differences[0].type).toBe('type_mismatch');
297
+ });
298
+ });
299
+
300
+ describe('nested objects', () => {
301
+ test('should compare deeply nested values', () => {
302
+ const differ = new SnapshotDiffer({});
303
+ const expected: Snapshot = {
304
+ body: { level1: { level2: { level3: { value: 'old' } } } },
305
+ hash: 'a',
306
+ updatedAt: '',
307
+ };
308
+ const received: Snapshot = {
309
+ body: { level1: { level2: { level3: { value: 'new' } } } },
310
+ hash: 'b',
311
+ updatedAt: '',
312
+ };
313
+
314
+ const result = differ.compare(expected, received);
315
+ expect(result.match).toBe(false);
316
+ expect(result.differences[0].path).toBe('body.level1.level2.level3.value');
317
+ });
318
+ });
319
+
320
+ describe('header comparison', () => {
321
+ test('should compare headers', () => {
322
+ const differ = new SnapshotDiffer({});
323
+ const expected: Snapshot = {
324
+ headers: { 'content-type': 'application/json' },
325
+ hash: 'a',
326
+ updatedAt: '',
327
+ };
328
+ const received: Snapshot = {
329
+ headers: { 'content-type': 'text/html' },
330
+ hash: 'b',
331
+ updatedAt: '',
332
+ };
333
+
334
+ const result = differ.compare(expected, received);
335
+ expect(result.match).toBe(false);
336
+ expect(result.differences[0].path).toBe('headers.content-type');
337
+ });
338
+
339
+ test('should exclude headers', () => {
340
+ const differ = new SnapshotDiffer({
341
+ exclude: ['headers.date', 'headers.x-request-id'],
342
+ });
343
+ const expected: Snapshot = {
344
+ headers: { 'content-type': 'application/json', date: '2024-01-01' },
345
+ hash: 'a',
346
+ updatedAt: '',
347
+ };
348
+ const received: Snapshot = {
349
+ headers: { 'content-type': 'application/json', date: '2024-12-31' },
350
+ hash: 'b',
351
+ updatedAt: '',
352
+ };
353
+
354
+ const result = differ.compare(expected, received);
355
+ expect(result.match).toBe(true);
356
+ });
357
+ });
358
+ });
@@ -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
+ }