@curl-runner/cli 1.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@curl-runner/cli",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "A powerful CLI tool for HTTP request management using YAML configuration",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
package/src/cli.ts CHANGED
@@ -166,6 +166,38 @@ class CurlRunnerCLI {
166
166
  };
167
167
  }
168
168
 
169
+ // Snapshot configuration
170
+ if (process.env.CURL_RUNNER_SNAPSHOT) {
171
+ envConfig.snapshot = {
172
+ ...envConfig.snapshot,
173
+ enabled: process.env.CURL_RUNNER_SNAPSHOT.toLowerCase() === 'true',
174
+ };
175
+ }
176
+
177
+ if (process.env.CURL_RUNNER_SNAPSHOT_UPDATE) {
178
+ const mode = process.env.CURL_RUNNER_SNAPSHOT_UPDATE.toLowerCase();
179
+ if (['none', 'all', 'failing'].includes(mode)) {
180
+ envConfig.snapshot = {
181
+ ...envConfig.snapshot,
182
+ updateMode: mode as 'none' | 'all' | 'failing',
183
+ };
184
+ }
185
+ }
186
+
187
+ if (process.env.CURL_RUNNER_SNAPSHOT_DIR) {
188
+ envConfig.snapshot = {
189
+ ...envConfig.snapshot,
190
+ dir: process.env.CURL_RUNNER_SNAPSHOT_DIR,
191
+ };
192
+ }
193
+
194
+ if (process.env.CURL_RUNNER_SNAPSHOT_CI) {
195
+ envConfig.snapshot = {
196
+ ...envConfig.snapshot,
197
+ ci: process.env.CURL_RUNNER_SNAPSHOT_CI.toLowerCase() === 'true',
198
+ };
199
+ }
200
+
169
201
  return envConfig;
170
202
  }
171
203
 
@@ -311,6 +343,33 @@ class CurlRunnerCLI {
311
343
  };
312
344
  }
313
345
 
346
+ // Apply snapshot options
347
+ if (options.snapshot !== undefined) {
348
+ globalConfig.snapshot = {
349
+ ...globalConfig.snapshot,
350
+ enabled: options.snapshot as boolean,
351
+ };
352
+ }
353
+ if (options.snapshotUpdate !== undefined) {
354
+ globalConfig.snapshot = {
355
+ ...globalConfig.snapshot,
356
+ enabled: true,
357
+ updateMode: options.snapshotUpdate as 'none' | 'all' | 'failing',
358
+ };
359
+ }
360
+ if (options.snapshotDir !== undefined) {
361
+ globalConfig.snapshot = {
362
+ ...globalConfig.snapshot,
363
+ dir: options.snapshotDir as string,
364
+ };
365
+ }
366
+ if (options.snapshotCi !== undefined) {
367
+ globalConfig.snapshot = {
368
+ ...globalConfig.snapshot,
369
+ ci: options.snapshotCi as boolean,
370
+ };
371
+ }
372
+
314
373
  if (allRequests.length === 0) {
315
374
  this.logger.logError('No requests found in YAML files');
316
375
  process.exit(1);
@@ -450,6 +509,14 @@ class CurlRunnerCLI {
450
509
  options.watchClear = true;
451
510
  } else if (key === 'no-watch-clear') {
452
511
  options.watchClear = false;
512
+ } else if (key === 'snapshot') {
513
+ options.snapshot = true;
514
+ } else if (key === 'update-snapshots') {
515
+ options.snapshotUpdate = 'all';
516
+ } else if (key === 'update-failing') {
517
+ options.snapshotUpdate = 'failing';
518
+ } else if (key === 'ci-snapshot') {
519
+ options.snapshotCi = true;
453
520
  } else if (nextArg && !nextArg.startsWith('--')) {
454
521
  if (key === 'continue-on-error') {
455
522
  options.continueOnError = nextArg === 'true';
@@ -483,6 +550,8 @@ class CurlRunnerCLI {
483
550
  }
484
551
  } else if (key === 'watch-debounce') {
485
552
  options.watchDebounce = Number.parseInt(nextArg, 10);
553
+ } else if (key === 'snapshot-dir') {
554
+ options.snapshotDir = nextArg;
486
555
  } else {
487
556
  options[key] = nextArg;
488
557
  }
@@ -512,6 +581,12 @@ class CurlRunnerCLI {
512
581
  case 'w':
513
582
  options.watch = true;
514
583
  break;
584
+ case 's':
585
+ options.snapshot = true;
586
+ break;
587
+ case 'u':
588
+ options.snapshotUpdate = 'all';
589
+ break;
515
590
  case 'o': {
516
591
  // Handle -o flag for output file
517
592
  const outputArg = args[i + 1];
@@ -641,6 +716,7 @@ class CurlRunnerCLI {
641
716
  defaults: { ...base.defaults, ...override.defaults },
642
717
  ci: { ...base.ci, ...override.ci },
643
718
  watch: { ...base.watch, ...override.watch },
719
+ snapshot: { ...base.snapshot, ...override.snapshot },
644
720
  };
645
721
  }
646
722
 
@@ -733,6 +809,13 @@ ${this.logger.color('CI/CD OPTIONS:', 'yellow')}
733
809
  --fail-on <count> Exit with code 1 if failures exceed this count
734
810
  --fail-on-percentage <pct> Exit with code 1 if failure percentage exceeds this value
735
811
 
812
+ ${this.logger.color('SNAPSHOT OPTIONS:', 'yellow')}
813
+ -s, --snapshot Enable snapshot testing
814
+ -u, --update-snapshots Update all snapshots
815
+ --update-failing Update only failing snapshots
816
+ --snapshot-dir <dir> Custom snapshot directory (default: __snapshots__)
817
+ --ci-snapshot Fail if snapshot is missing (CI mode)
818
+
736
819
  ${this.logger.color('EXAMPLES:', 'yellow')}
737
820
  # Run all YAML files in current directory
738
821
  curl-runner
@@ -782,6 +865,15 @@ ${this.logger.color('EXAMPLES:', 'yellow')}
782
865
  # Watch with custom debounce
783
866
  curl-runner tests/ -w --watch-debounce 500
784
867
 
868
+ # Snapshot testing - save and compare responses
869
+ curl-runner api.yaml --snapshot
870
+
871
+ # Update all snapshots
872
+ curl-runner api.yaml -su
873
+
874
+ # CI mode - fail if snapshot missing
875
+ curl-runner api.yaml --snapshot --ci-snapshot
876
+
785
877
  ${this.logger.color('YAML STRUCTURE:', 'yellow')}
786
878
  Single request:
787
879
  request:
@@ -1,4 +1,5 @@
1
1
  import { YamlParser } from '../parser/yaml';
2
+ import { SnapshotManager } from '../snapshot/snapshot-manager';
2
3
  import type {
3
4
  ExecutionResult,
4
5
  ExecutionSummary,
@@ -8,6 +9,7 @@ import type {
8
9
  JsonValue,
9
10
  RequestConfig,
10
11
  ResponseStoreContext,
12
+ SnapshotConfig,
11
13
  } from '../types/config';
12
14
  import { CurlBuilder } from '../utils/curl-builder';
13
15
  import { Logger } from '../utils/logger';
@@ -16,10 +18,12 @@ import { createStoreContext, extractStoreValues } from '../utils/response-store'
16
18
  export class RequestExecutor {
17
19
  private logger: Logger;
18
20
  private globalConfig: GlobalConfig;
21
+ private snapshotManager: SnapshotManager;
19
22
 
20
23
  constructor(globalConfig: GlobalConfig = {}) {
21
24
  this.globalConfig = globalConfig;
22
25
  this.logger = new Logger(globalConfig.output);
26
+ this.snapshotManager = new SnapshotManager(globalConfig.snapshot);
23
27
  }
24
28
 
25
29
  private mergeOutputConfig(config: RequestConfig): GlobalConfig['output'] {
@@ -30,6 +34,13 @@ export class RequestExecutor {
30
34
  };
31
35
  }
32
36
 
37
+ /**
38
+ * Gets the effective snapshot config for a request.
39
+ */
40
+ private getSnapshotConfig(config: RequestConfig): SnapshotConfig | null {
41
+ return SnapshotManager.mergeConfig(this.globalConfig.snapshot, config.snapshot);
42
+ }
43
+
33
44
  /**
34
45
  * Checks if a form field value is a file attachment.
35
46
  */
@@ -140,6 +151,25 @@ export class RequestExecutor {
140
151
  }
141
152
  }
142
153
 
154
+ // Snapshot testing
155
+ const snapshotConfig = this.getSnapshotConfig(config);
156
+ if (snapshotConfig && config.sourceFile) {
157
+ const snapshotResult = await this.snapshotManager.compareAndUpdate(
158
+ config.sourceFile,
159
+ config.name || 'Request',
160
+ executionResult,
161
+ snapshotConfig,
162
+ );
163
+ executionResult.snapshotResult = snapshotResult;
164
+
165
+ if (!snapshotResult.match && !snapshotResult.updated) {
166
+ executionResult.success = false;
167
+ if (!executionResult.error) {
168
+ executionResult.error = 'Snapshot mismatch';
169
+ }
170
+ }
171
+ }
172
+
143
173
  requestLogger.logRequestComplete(executionResult);
144
174
  return executionResult;
145
175
  }
@@ -0,0 +1,3 @@
1
+ export { SnapshotDiffer } from './snapshot-differ';
2
+ export { SnapshotFormatter, type SnapshotStats } from './snapshot-formatter';
3
+ export { filterSnapshotBody, SnapshotManager } from './snapshot-manager';
@@ -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
+ });