@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@curl-runner/cli",
3
- "version": "1.10.0",
3
+ "version": "1.12.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,7 +9,9 @@ import type {
8
9
  JsonValue,
9
10
  RequestConfig,
10
11
  ResponseStoreContext,
12
+ SnapshotConfig,
11
13
  } from '../types/config';
14
+ import { evaluateCondition } from '../utils/condition-evaluator';
12
15
  import { CurlBuilder } from '../utils/curl-builder';
13
16
  import { Logger } from '../utils/logger';
14
17
  import { createStoreContext, extractStoreValues } from '../utils/response-store';
@@ -16,10 +19,12 @@ import { createStoreContext, extractStoreValues } from '../utils/response-store'
16
19
  export class RequestExecutor {
17
20
  private logger: Logger;
18
21
  private globalConfig: GlobalConfig;
22
+ private snapshotManager: SnapshotManager;
19
23
 
20
24
  constructor(globalConfig: GlobalConfig = {}) {
21
25
  this.globalConfig = globalConfig;
22
26
  this.logger = new Logger(globalConfig.output);
27
+ this.snapshotManager = new SnapshotManager(globalConfig.snapshot);
23
28
  }
24
29
 
25
30
  private mergeOutputConfig(config: RequestConfig): GlobalConfig['output'] {
@@ -30,6 +35,13 @@ export class RequestExecutor {
30
35
  };
31
36
  }
32
37
 
38
+ /**
39
+ * Gets the effective snapshot config for a request.
40
+ */
41
+ private getSnapshotConfig(config: RequestConfig): SnapshotConfig | null {
42
+ return SnapshotManager.mergeConfig(this.globalConfig.snapshot, config.snapshot);
43
+ }
44
+
33
45
  /**
34
46
  * Checks if a form field value is a file attachment.
35
47
  */
@@ -140,6 +152,25 @@ export class RequestExecutor {
140
152
  }
141
153
  }
142
154
 
155
+ // Snapshot testing
156
+ const snapshotConfig = this.getSnapshotConfig(config);
157
+ if (snapshotConfig && config.sourceFile) {
158
+ const snapshotResult = await this.snapshotManager.compareAndUpdate(
159
+ config.sourceFile,
160
+ config.name || 'Request',
161
+ executionResult,
162
+ snapshotConfig,
163
+ );
164
+ executionResult.snapshotResult = snapshotResult;
165
+
166
+ if (!snapshotResult.match && !snapshotResult.updated) {
167
+ executionResult.success = false;
168
+ if (!executionResult.error) {
169
+ executionResult.error = 'Snapshot mismatch';
170
+ }
171
+ }
172
+ }
173
+
143
174
  requestLogger.logRequestComplete(executionResult);
144
175
  return executionResult;
145
176
  }
@@ -486,6 +517,23 @@ export class RequestExecutor {
486
517
  for (let i = 0; i < requests.length; i++) {
487
518
  // Interpolate store variables before execution
488
519
  const interpolatedRequest = this.interpolateStoreVariables(requests[i], storeContext);
520
+
521
+ // Evaluate `when` condition if present
522
+ if (interpolatedRequest.when) {
523
+ const conditionResult = evaluateCondition(interpolatedRequest.when, storeContext);
524
+ if (!conditionResult.shouldRun) {
525
+ const skippedResult: ExecutionResult = {
526
+ request: interpolatedRequest,
527
+ success: true, // Skipped requests are not failures
528
+ skipped: true,
529
+ skipReason: conditionResult.reason,
530
+ };
531
+ results.push(skippedResult);
532
+ this.logger.logSkipped(interpolatedRequest, i + 1, conditionResult.reason);
533
+ continue;
534
+ }
535
+ }
536
+
489
537
  const result = await this.executeRequest(interpolatedRequest, i + 1);
490
538
  results.push(result);
491
539
 
@@ -588,13 +636,15 @@ export class RequestExecutor {
588
636
  }
589
637
 
590
638
  private createSummary(results: ExecutionResult[], duration: number): ExecutionSummary {
591
- const successful = results.filter((r) => r.success).length;
639
+ const skipped = results.filter((r) => r.skipped).length;
640
+ const successful = results.filter((r) => r.success && !r.skipped).length;
592
641
  const failed = results.filter((r) => !r.success).length;
593
642
 
594
643
  return {
595
644
  total: results.length,
596
645
  successful,
597
646
  failed,
647
+ skipped,
598
648
  duration,
599
649
  results,
600
650
  };
@@ -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
+ });