@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@curl-runner/cli",
3
- "version": "1.9.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
@@ -3,10 +3,17 @@
3
3
  import { Glob } from 'bun';
4
4
  import { RequestExecutor } from './executor/request-executor';
5
5
  import { YamlParser } from './parser/yaml';
6
- import type { ExecutionSummary, GlobalConfig, RequestConfig } from './types/config';
6
+ import type {
7
+ ExecutionResult,
8
+ ExecutionSummary,
9
+ GlobalConfig,
10
+ RequestConfig,
11
+ WatchConfig,
12
+ } from './types/config';
7
13
  import { Logger } from './utils/logger';
8
14
  import { VersionChecker } from './utils/version-checker';
9
15
  import { getVersion } from './version';
16
+ import { FileWatcher } from './watcher/file-watcher';
10
17
 
11
18
  class CurlRunnerCLI {
12
19
  private logger = new Logger();
@@ -137,6 +144,60 @@ class CurlRunnerCLI {
137
144
  }
138
145
  }
139
146
 
147
+ // Watch mode configuration
148
+ if (process.env.CURL_RUNNER_WATCH) {
149
+ envConfig.watch = {
150
+ ...envConfig.watch,
151
+ enabled: process.env.CURL_RUNNER_WATCH.toLowerCase() === 'true',
152
+ };
153
+ }
154
+
155
+ if (process.env.CURL_RUNNER_WATCH_DEBOUNCE) {
156
+ envConfig.watch = {
157
+ ...envConfig.watch,
158
+ debounce: Number.parseInt(process.env.CURL_RUNNER_WATCH_DEBOUNCE, 10),
159
+ };
160
+ }
161
+
162
+ if (process.env.CURL_RUNNER_WATCH_CLEAR) {
163
+ envConfig.watch = {
164
+ ...envConfig.watch,
165
+ clear: process.env.CURL_RUNNER_WATCH_CLEAR.toLowerCase() !== 'false',
166
+ };
167
+ }
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
+
140
201
  return envConfig;
141
202
  }
142
203
 
@@ -282,63 +343,139 @@ class CurlRunnerCLI {
282
343
  };
283
344
  }
284
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
+
285
373
  if (allRequests.length === 0) {
286
374
  this.logger.logError('No requests found in YAML files');
287
375
  process.exit(1);
288
376
  }
289
377
 
290
- const executor = new RequestExecutor(globalConfig);
291
- let summary: ExecutionSummary;
378
+ // Check if watch mode is enabled
379
+ const watchEnabled = options.watch || globalConfig.watch?.enabled;
380
+
381
+ if (watchEnabled) {
382
+ // Build watch config from options and global config
383
+ const watchConfig: WatchConfig = {
384
+ enabled: true,
385
+ debounce:
386
+ (options.watchDebounce as number | undefined) ?? globalConfig.watch?.debounce ?? 300,
387
+ clear: (options.watchClear as boolean | undefined) ?? globalConfig.watch?.clear ?? true,
388
+ };
292
389
 
293
- // If multiple files, execute them with file separators for clarity
294
- if (fileGroups.length > 1) {
295
- const allResults: ExecutionResult[] = [];
296
- let totalDuration = 0;
390
+ const watcher = new FileWatcher({
391
+ files: yamlFiles,
392
+ config: watchConfig,
393
+ logger: this.logger,
394
+ onRun: async () => {
395
+ await this.executeRequests(yamlFiles, globalConfig);
396
+ },
397
+ });
297
398
 
298
- for (let i = 0; i < fileGroups.length; i++) {
299
- const group = fileGroups[i];
399
+ await watcher.start();
400
+ } else {
401
+ // Normal execution mode
402
+ const summary = await this.executeRequests(yamlFiles, globalConfig);
403
+ const exitCode = this.determineExitCode(summary, globalConfig);
404
+ process.exit(exitCode);
405
+ }
406
+ } catch (error) {
407
+ this.logger.logError(error instanceof Error ? error.message : String(error));
408
+ process.exit(1);
409
+ }
410
+ }
300
411
 
301
- // Show file header for better organization
302
- this.logger.logFileHeader(group.file, group.requests.length);
412
+ private async executeRequests(
413
+ yamlFiles: string[],
414
+ globalConfig: GlobalConfig,
415
+ ): Promise<ExecutionSummary> {
416
+ // Process YAML files and collect requests
417
+ const fileGroups: Array<{ file: string; requests: RequestConfig[]; config?: GlobalConfig }> =
418
+ [];
419
+ const allRequests: RequestConfig[] = [];
420
+
421
+ for (const file of yamlFiles) {
422
+ const { requests, config } = await this.processYamlFile(file);
423
+
424
+ const fileOutputConfig = config?.output || {};
425
+ const requestsWithSourceConfig = requests.map((request) => ({
426
+ ...request,
427
+ sourceOutputConfig: fileOutputConfig,
428
+ sourceFile: file,
429
+ }));
430
+
431
+ fileGroups.push({ file, requests: requestsWithSourceConfig, config });
432
+ allRequests.push(...requestsWithSourceConfig);
433
+ }
303
434
 
304
- const fileSummary = await executor.execute(group.requests);
305
- allResults.push(...fileSummary.results);
306
- totalDuration += fileSummary.duration;
435
+ const executor = new RequestExecutor(globalConfig);
436
+ let summary: ExecutionSummary;
307
437
 
308
- // Don't show individual file summaries for cleaner output
438
+ // If multiple files, execute them with file separators for clarity
439
+ if (fileGroups.length > 1) {
440
+ const allResults: ExecutionResult[] = [];
441
+ let totalDuration = 0;
309
442
 
310
- // Add spacing between files (except for the last one)
311
- if (i < fileGroups.length - 1) {
312
- console.log();
313
- }
314
- }
443
+ for (let i = 0; i < fileGroups.length; i++) {
444
+ const group = fileGroups[i];
315
445
 
316
- // Create combined summary
317
- const successful = allResults.filter((r) => r.success).length;
318
- const failed = allResults.filter((r) => !r.success).length;
446
+ // Show file header for better organization
447
+ this.logger.logFileHeader(group.file, group.requests.length);
319
448
 
320
- summary = {
321
- total: allResults.length,
322
- successful,
323
- failed,
324
- duration: totalDuration,
325
- results: allResults,
326
- };
449
+ const fileSummary = await executor.execute(group.requests);
450
+ allResults.push(...fileSummary.results);
451
+ totalDuration += fileSummary.duration;
327
452
 
328
- // Show final summary
329
- executor.logger.logSummary(summary, true);
330
- } else {
331
- // Single file - use normal execution
332
- summary = await executor.execute(allRequests);
453
+ // Add spacing between files (except for the last one)
454
+ if (i < fileGroups.length - 1) {
455
+ console.log();
456
+ }
333
457
  }
334
458
 
335
- // Determine exit code based on CI configuration
336
- const exitCode = this.determineExitCode(summary, globalConfig);
337
- process.exit(exitCode);
338
- } catch (error) {
339
- this.logger.logError(error instanceof Error ? error.message : String(error));
340
- process.exit(1);
459
+ // Create combined summary
460
+ const successful = allResults.filter((r) => r.success).length;
461
+ const failed = allResults.filter((r) => !r.success).length;
462
+
463
+ summary = {
464
+ total: allResults.length,
465
+ successful,
466
+ failed,
467
+ duration: totalDuration,
468
+ results: allResults,
469
+ };
470
+
471
+ // Show final summary
472
+ this.logger.logSummary(summary, true);
473
+ } else {
474
+ // Single file - use normal execution
475
+ summary = await executor.execute(allRequests);
341
476
  }
477
+
478
+ return summary;
342
479
  }
343
480
 
344
481
  private parseArguments(args: string[]): { files: string[]; options: Record<string, unknown> } {
@@ -366,6 +503,20 @@ class CurlRunnerCLI {
366
503
  options.showMetrics = true;
367
504
  } else if (key === 'strict-exit') {
368
505
  options.strictExit = true;
506
+ } else if (key === 'watch') {
507
+ options.watch = true;
508
+ } else if (key === 'watch-clear') {
509
+ options.watchClear = true;
510
+ } else if (key === 'no-watch-clear') {
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;
369
520
  } else if (nextArg && !nextArg.startsWith('--')) {
370
521
  if (key === 'continue-on-error') {
371
522
  options.continueOnError = nextArg === 'true';
@@ -397,6 +548,10 @@ class CurlRunnerCLI {
397
548
  if (['minimal', 'standard', 'detailed'].includes(nextArg)) {
398
549
  options.prettyLevel = nextArg;
399
550
  }
551
+ } else if (key === 'watch-debounce') {
552
+ options.watchDebounce = Number.parseInt(nextArg, 10);
553
+ } else if (key === 'snapshot-dir') {
554
+ options.snapshotDir = nextArg;
400
555
  } else {
401
556
  options[key] = nextArg;
402
557
  }
@@ -423,6 +578,15 @@ class CurlRunnerCLI {
423
578
  case 'q':
424
579
  options.quiet = true;
425
580
  break;
581
+ case 'w':
582
+ options.watch = true;
583
+ break;
584
+ case 's':
585
+ options.snapshot = true;
586
+ break;
587
+ case 'u':
588
+ options.snapshotUpdate = 'all';
589
+ break;
426
590
  case 'o': {
427
591
  // Handle -o flag for output file
428
592
  const outputArg = args[i + 1];
@@ -551,6 +715,8 @@ class CurlRunnerCLI {
551
715
  output: { ...base.output, ...override.output },
552
716
  defaults: { ...base.defaults, ...override.defaults },
553
717
  ci: { ...base.ci, ...override.ci },
718
+ watch: { ...base.watch, ...override.watch },
719
+ snapshot: { ...base.snapshot, ...override.snapshot },
554
720
  };
555
721
  }
556
722
 
@@ -633,11 +799,23 @@ ${this.logger.color('OPTIONS:', 'yellow')}
633
799
  --show-metrics Include performance metrics in output
634
800
  --version Show version
635
801
 
802
+ ${this.logger.color('WATCH MODE:', 'yellow')}
803
+ -w, --watch Watch files and re-run on changes
804
+ --watch-debounce <ms> Debounce delay for watch mode (default: 300)
805
+ --no-watch-clear Don't clear screen between watch runs
806
+
636
807
  ${this.logger.color('CI/CD OPTIONS:', 'yellow')}
637
808
  --strict-exit Exit with code 1 if any validation fails (for CI/CD)
638
809
  --fail-on <count> Exit with code 1 if failures exceed this count
639
810
  --fail-on-percentage <pct> Exit with code 1 if failure percentage exceeds this value
640
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
+
641
819
  ${this.logger.color('EXAMPLES:', 'yellow')}
642
820
  # Run all YAML files in current directory
643
821
  curl-runner
@@ -681,6 +859,21 @@ ${this.logger.color('EXAMPLES:', 'yellow')}
681
859
  # CI/CD: Allow up to 10% failures
682
860
  curl-runner tests/ --fail-on-percentage 10
683
861
 
862
+ # Watch mode - re-run on file changes
863
+ curl-runner api.yaml --watch
864
+
865
+ # Watch with custom debounce
866
+ curl-runner tests/ -w --watch-debounce 500
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
+
684
877
  ${this.logger.color('YAML STRUCTURE:', 'yellow')}
685
878
  Single request:
686
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
  */
@@ -102,7 +113,7 @@ export class RequestExecutor {
102
113
  requestLogger.logRetry(attempt, maxAttempts - 1);
103
114
  if (config.retry?.delay) {
104
115
  const backoff = config.retry.backoff ?? 1;
105
- const delay = config.retry.delay * Math.pow(backoff, attempt - 1);
116
+ const delay = config.retry.delay * backoff ** (attempt - 1);
106
117
  await Bun.sleep(delay);
107
118
  }
108
119
  }
@@ -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';