@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.
- package/package.json +1 -1
- package/src/cli.ts +234 -41
- package/src/executor/request-executor.ts +31 -1
- package/src/snapshot/index.ts +3 -0
- package/src/snapshot/snapshot-differ.test.ts +358 -0
- package/src/snapshot/snapshot-differ.ts +296 -0
- package/src/snapshot/snapshot-formatter.ts +170 -0
- package/src/snapshot/snapshot-manager.test.ts +204 -0
- package/src/snapshot/snapshot-manager.ts +342 -0
- package/src/types/config.ts +98 -0
- package/src/utils/logger.ts +49 -0
- package/src/watcher/file-watcher.test.ts +186 -0
- package/src/watcher/file-watcher.ts +140 -0
|
@@ -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
|
+
}
|