@curl-runner/cli 1.14.0 → 1.15.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.14.0",
3
+ "version": "1.15.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
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { Glob } from 'bun';
4
+ import { BaselineManager, DiffFormatter, DiffOrchestrator } from './diff';
4
5
  import { ProfileExecutor } from './executor/profile-executor';
5
6
  import { RequestExecutor } from './executor/request-executor';
6
7
  import { YamlParser } from './parser/yaml';
7
8
  import type {
9
+ DiffConfig,
8
10
  ExecutionResult,
9
11
  ExecutionSummary,
10
12
  GlobalConfig,
13
+ GlobalDiffConfig,
11
14
  ProfileConfig,
12
15
  RequestConfig,
13
16
  WatchConfig,
@@ -244,6 +247,52 @@ class CurlRunnerCLI {
244
247
  };
245
248
  }
246
249
 
250
+ // Diff configuration
251
+ if (process.env.CURL_RUNNER_DIFF) {
252
+ envConfig.diff = {
253
+ ...envConfig.diff,
254
+ enabled: process.env.CURL_RUNNER_DIFF.toLowerCase() === 'true',
255
+ };
256
+ }
257
+
258
+ if (process.env.CURL_RUNNER_DIFF_SAVE) {
259
+ envConfig.diff = {
260
+ ...envConfig.diff,
261
+ save: process.env.CURL_RUNNER_DIFF_SAVE.toLowerCase() === 'true',
262
+ };
263
+ }
264
+
265
+ if (process.env.CURL_RUNNER_DIFF_LABEL) {
266
+ envConfig.diff = {
267
+ ...envConfig.diff,
268
+ label: process.env.CURL_RUNNER_DIFF_LABEL,
269
+ };
270
+ }
271
+
272
+ if (process.env.CURL_RUNNER_DIFF_COMPARE) {
273
+ envConfig.diff = {
274
+ ...envConfig.diff,
275
+ compareWith: process.env.CURL_RUNNER_DIFF_COMPARE,
276
+ };
277
+ }
278
+
279
+ if (process.env.CURL_RUNNER_DIFF_DIR) {
280
+ envConfig.diff = {
281
+ ...envConfig.diff,
282
+ dir: process.env.CURL_RUNNER_DIFF_DIR,
283
+ };
284
+ }
285
+
286
+ if (process.env.CURL_RUNNER_DIFF_OUTPUT) {
287
+ const format = process.env.CURL_RUNNER_DIFF_OUTPUT.toLowerCase();
288
+ if (['terminal', 'json', 'markdown'].includes(format)) {
289
+ envConfig.diff = {
290
+ ...envConfig.diff,
291
+ outputFormat: format as 'terminal' | 'json' | 'markdown',
292
+ };
293
+ }
294
+ }
295
+
247
296
  return envConfig;
248
297
  }
249
298
 
@@ -269,6 +318,12 @@ class CurlRunnerCLI {
269
318
  return;
270
319
  }
271
320
 
321
+ // Handle diff subcommand: curl-runner diff <label1> <label2> [file]
322
+ if (args[0] === 'diff' && args.length >= 3) {
323
+ await this.executeDiffSubcommand(args.slice(1), options);
324
+ return;
325
+ }
326
+
272
327
  // Load configuration from environment variables, config file, then CLI options
273
328
  const envConfig = this.loadEnvironmentVariables();
274
329
  const configFile = await this.loadConfigFile();
@@ -416,6 +471,46 @@ class CurlRunnerCLI {
416
471
  };
417
472
  }
418
473
 
474
+ // Apply diff options
475
+ if (options.diff !== undefined) {
476
+ globalConfig.diff = {
477
+ ...globalConfig.diff,
478
+ enabled: options.diff as boolean,
479
+ };
480
+ }
481
+ if (options.diffSave !== undefined) {
482
+ globalConfig.diff = {
483
+ ...globalConfig.diff,
484
+ enabled: true,
485
+ save: options.diffSave as boolean,
486
+ };
487
+ }
488
+ if (options.diffLabel !== undefined) {
489
+ globalConfig.diff = {
490
+ ...globalConfig.diff,
491
+ label: options.diffLabel as string,
492
+ };
493
+ }
494
+ if (options.diffCompare !== undefined) {
495
+ globalConfig.diff = {
496
+ ...globalConfig.diff,
497
+ enabled: true,
498
+ compareWith: options.diffCompare as string,
499
+ };
500
+ }
501
+ if (options.diffDir !== undefined) {
502
+ globalConfig.diff = {
503
+ ...globalConfig.diff,
504
+ dir: options.diffDir as string,
505
+ };
506
+ }
507
+ if (options.diffOutput !== undefined) {
508
+ globalConfig.diff = {
509
+ ...globalConfig.diff,
510
+ outputFormat: options.diffOutput as 'terminal' | 'json' | 'markdown',
511
+ };
512
+ }
513
+
419
514
  if (allRequests.length === 0) {
420
515
  this.logger.logError('No requests found in YAML files');
421
516
  process.exit(1);
@@ -593,9 +688,138 @@ class CurlRunnerCLI {
593
688
  summary = await executor.execute(allRequests);
594
689
  }
595
690
 
691
+ // Handle diff mode
692
+ if (globalConfig.diff?.enabled || globalConfig.diff?.save || globalConfig.diff?.compareWith) {
693
+ await this.handleDiffMode(yamlFiles[0], summary.results, globalConfig.diff);
694
+ }
695
+
596
696
  return summary;
597
697
  }
598
698
 
699
+ private async handleDiffMode(
700
+ yamlPath: string,
701
+ results: ExecutionResult[],
702
+ diffConfig: GlobalDiffConfig,
703
+ ): Promise<void> {
704
+ const orchestrator = new DiffOrchestrator(diffConfig);
705
+ const formatter = new DiffFormatter(diffConfig.outputFormat || 'terminal');
706
+ const config: DiffConfig = BaselineManager.mergeConfig(diffConfig, true) || {};
707
+
708
+ const currentLabel = diffConfig.label || 'current';
709
+ const compareLabel = diffConfig.compareWith;
710
+
711
+ // Save baseline if requested
712
+ if (diffConfig.save) {
713
+ await orchestrator.saveBaseline(yamlPath, currentLabel, results, config);
714
+ this.logger.logInfo(`Baseline saved as '${currentLabel}'`);
715
+ }
716
+
717
+ // Compare with baseline if requested
718
+ if (compareLabel) {
719
+ const diffSummary = await orchestrator.compareWithBaseline(
720
+ yamlPath,
721
+ results,
722
+ currentLabel,
723
+ compareLabel,
724
+ config,
725
+ );
726
+
727
+ // Check if baseline exists
728
+ if (diffSummary.newBaselines === diffSummary.totalRequests) {
729
+ this.logger.logWarning(
730
+ `No baseline '${compareLabel}' found. Saving current run as baseline.`,
731
+ );
732
+ await orchestrator.saveBaseline(yamlPath, compareLabel, results, config);
733
+ return;
734
+ }
735
+
736
+ const output = formatter.formatSummary(diffSummary, compareLabel, currentLabel);
737
+ console.log(output);
738
+
739
+ // Save current as baseline if configured
740
+ if (diffConfig.save) {
741
+ await orchestrator.saveBaseline(yamlPath, currentLabel, results, config);
742
+ }
743
+ } else if (diffConfig.enabled && !diffConfig.save) {
744
+ // Auto-detect: list available baselines or save first baseline
745
+ const labels = await orchestrator.listLabels(yamlPath);
746
+
747
+ if (labels.length === 0) {
748
+ // No baselines exist - save current as default baseline
749
+ await orchestrator.saveBaseline(yamlPath, 'baseline', results, config);
750
+ this.logger.logInfo(`No baselines found. Saved current run as 'baseline'.`);
751
+ } else if (labels.length === 1) {
752
+ // One baseline exists - compare against it
753
+ const diffSummary = await orchestrator.compareWithBaseline(
754
+ yamlPath,
755
+ results,
756
+ currentLabel,
757
+ labels[0],
758
+ config,
759
+ );
760
+ const output = formatter.formatSummary(diffSummary, labels[0], currentLabel);
761
+ console.log(output);
762
+ } else {
763
+ // Multiple baselines - list them
764
+ this.logger.logInfo(`Available baselines: ${labels.join(', ')}`);
765
+ this.logger.logInfo(`Use --diff-compare <label> to compare against a specific baseline.`);
766
+ }
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Executes the diff subcommand to compare two stored baselines.
772
+ * Usage: curl-runner diff <label1> <label2> [file.yaml]
773
+ */
774
+ private async executeDiffSubcommand(
775
+ args: string[],
776
+ options: Record<string, unknown>,
777
+ ): Promise<void> {
778
+ const label1 = args[0];
779
+ const label2 = args[1];
780
+ let yamlFile = args[2];
781
+
782
+ // Find YAML file if not specified
783
+ if (!yamlFile) {
784
+ const yamlFiles = await this.findYamlFiles([], options);
785
+ if (yamlFiles.length === 0) {
786
+ this.logger.logError(
787
+ 'No YAML files found. Specify a file: curl-runner diff <label1> <label2> <file.yaml>',
788
+ );
789
+ process.exit(1);
790
+ }
791
+ if (yamlFiles.length > 1) {
792
+ this.logger.logError('Multiple YAML files found. Specify which file to use.');
793
+ process.exit(1);
794
+ }
795
+ yamlFile = yamlFiles[0];
796
+ }
797
+
798
+ const diffConfig: GlobalDiffConfig = {
799
+ dir: (options.diffDir as string) || '__baselines__',
800
+ outputFormat: (options.diffOutput as 'terminal' | 'json' | 'markdown') || 'terminal',
801
+ };
802
+
803
+ const orchestrator = new DiffOrchestrator(diffConfig);
804
+ const formatter = new DiffFormatter(diffConfig.outputFormat || 'terminal');
805
+ const config: DiffConfig = { exclude: [], match: {} };
806
+
807
+ try {
808
+ const diffSummary = await orchestrator.compareTwoBaselines(yamlFile, label1, label2, config);
809
+ const output = formatter.formatSummary(diffSummary, label1, label2);
810
+ console.log(output);
811
+
812
+ // Exit with code 1 if differences found
813
+ if (diffSummary.changed > 0) {
814
+ process.exit(1);
815
+ }
816
+ process.exit(0);
817
+ } catch (error) {
818
+ this.logger.logError(error instanceof Error ? error.message : String(error));
819
+ process.exit(1);
820
+ }
821
+ }
822
+
599
823
  private parseArguments(args: string[]): { files: string[]; options: Record<string, unknown> } {
600
824
  const options: Record<string, unknown> = {};
601
825
  const files: string[] = [];
@@ -637,6 +861,10 @@ class CurlRunnerCLI {
637
861
  options.snapshotUpdate = 'failing';
638
862
  } else if (key === 'ci-snapshot') {
639
863
  options.snapshotCi = true;
864
+ } else if (key === 'diff') {
865
+ options.diff = true;
866
+ } else if (key === 'diff-save') {
867
+ options.diffSave = true;
640
868
  } else if (nextArg && !nextArg.startsWith('--')) {
641
869
  if (key === 'continue-on-error') {
642
870
  options.continueOnError = nextArg === 'true';
@@ -680,6 +908,16 @@ class CurlRunnerCLI {
680
908
  options.profileExport = nextArg;
681
909
  } else if (key === 'snapshot-dir') {
682
910
  options.snapshotDir = nextArg;
911
+ } else if (key === 'diff-label') {
912
+ options.diffLabel = nextArg;
913
+ } else if (key === 'diff-compare') {
914
+ options.diffCompare = nextArg;
915
+ } else if (key === 'diff-dir') {
916
+ options.diffDir = nextArg;
917
+ } else if (key === 'diff-output') {
918
+ if (['terminal', 'json', 'markdown'].includes(nextArg)) {
919
+ options.diffOutput = nextArg;
920
+ }
683
921
  } else {
684
922
  options[key] = nextArg;
685
923
  }
@@ -715,6 +953,9 @@ class CurlRunnerCLI {
715
953
  case 'u':
716
954
  options.snapshotUpdate = 'all';
717
955
  break;
956
+ case 'd':
957
+ options.diff = true;
958
+ break;
718
959
  case 'o': {
719
960
  // Handle -o flag for output file
720
961
  const outputArg = args[i + 1];
@@ -854,6 +1095,7 @@ class CurlRunnerCLI {
854
1095
  ci: { ...base.ci, ...override.ci },
855
1096
  watch: { ...base.watch, ...override.watch },
856
1097
  snapshot: { ...base.snapshot, ...override.snapshot },
1098
+ diff: { ...base.diff, ...override.diff },
857
1099
  };
858
1100
  }
859
1101
 
@@ -960,6 +1202,18 @@ ${this.logger.color('SNAPSHOT OPTIONS:', 'yellow')}
960
1202
  --snapshot-dir <dir> Custom snapshot directory (default: __snapshots__)
961
1203
  --ci-snapshot Fail if snapshot is missing (CI mode)
962
1204
 
1205
+ ${this.logger.color('DIFF OPTIONS:', 'yellow')}
1206
+ -d, --diff Enable response diffing (compare with baseline)
1207
+ --diff-save Save current run as baseline
1208
+ --diff-label <name> Label for current run (e.g., 'staging', 'v1.0')
1209
+ --diff-compare <label> Compare against this baseline label
1210
+ --diff-dir <dir> Baseline storage directory (default: __baselines__)
1211
+ --diff-output <format> Output format (terminal|json|markdown)
1212
+
1213
+ ${this.logger.color('DIFF SUBCOMMAND:', 'yellow')}
1214
+ curl-runner diff <label1> <label2> [file.yaml]
1215
+ Compare two stored baselines without making requests
1216
+
963
1217
  ${this.logger.color('EXAMPLES:', 'yellow')}
964
1218
  # Run all YAML files in current directory
965
1219
  curl-runner
@@ -1027,6 +1281,21 @@ ${this.logger.color('EXAMPLES:', 'yellow')}
1027
1281
  # CI mode - fail if snapshot missing
1028
1282
  curl-runner api.yaml --snapshot --ci-snapshot
1029
1283
 
1284
+ # Response diffing - save baseline for staging
1285
+ curl-runner api.yaml --diff-save --diff-label staging
1286
+
1287
+ # Compare current run against staging baseline
1288
+ curl-runner api.yaml --diff --diff-compare staging
1289
+
1290
+ # Compare staging vs production baselines (offline)
1291
+ curl-runner diff staging production api.yaml
1292
+
1293
+ # Auto-diff: creates baseline on first run, compares on subsequent runs
1294
+ curl-runner api.yaml --diff
1295
+
1296
+ # Diff with JSON output for CI
1297
+ curl-runner api.yaml --diff --diff-compare staging --diff-output json
1298
+
1030
1299
  ${this.logger.color('YAML STRUCTURE:', 'yellow')}
1031
1300
  Single request:
1032
1301
  request:
@@ -0,0 +1,181 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ExecutionResult } from '../types/config';
3
+ import { BaselineManager } from './baseline-manager';
4
+
5
+ describe('BaselineManager', () => {
6
+ describe('getBaselinePath', () => {
7
+ test('should generate correct baseline path', () => {
8
+ const manager = new BaselineManager({});
9
+ expect(manager.getBaselinePath('tests/api.yaml', 'staging')).toBe(
10
+ 'tests/__baselines__/api.staging.baseline.json',
11
+ );
12
+ });
13
+
14
+ test('should use custom directory', () => {
15
+ const manager = new BaselineManager({ dir: '.baselines' });
16
+ expect(manager.getBaselinePath('api.yaml', 'prod')).toBe('.baselines/api.prod.baseline.json');
17
+ });
18
+
19
+ test('should handle nested paths', () => {
20
+ const manager = new BaselineManager({});
21
+ expect(manager.getBaselinePath('tests/integration/users.yaml', 'staging')).toBe(
22
+ 'tests/integration/__baselines__/users.staging.baseline.json',
23
+ );
24
+ });
25
+
26
+ test('should handle labels with special characters', () => {
27
+ const manager = new BaselineManager({});
28
+ expect(manager.getBaselinePath('api.yaml', 'v1.0.0')).toBe(
29
+ '__baselines__/api.v1.0.0.baseline.json',
30
+ );
31
+ });
32
+ });
33
+
34
+ describe('getBaselineDir', () => {
35
+ test('should return correct directory', () => {
36
+ const manager = new BaselineManager({});
37
+ expect(manager.getBaselineDir('tests/api.yaml')).toBe('tests/__baselines__');
38
+ });
39
+
40
+ test('should use custom directory', () => {
41
+ const manager = new BaselineManager({ dir: 'snapshots' });
42
+ expect(manager.getBaselineDir('api.yaml')).toBe('snapshots');
43
+ });
44
+ });
45
+
46
+ describe('createBaseline', () => {
47
+ const mockResult: ExecutionResult = {
48
+ request: { url: 'https://api.example.com', name: 'Get Users' },
49
+ success: true,
50
+ status: 200,
51
+ headers: {
52
+ 'Content-Type': 'application/json',
53
+ 'X-Request-Id': 'abc123',
54
+ },
55
+ body: {
56
+ id: 1,
57
+ name: 'Test',
58
+ timestamp: '2024-01-01',
59
+ },
60
+ metrics: {
61
+ duration: 150,
62
+ },
63
+ };
64
+
65
+ test('should create baseline with all fields', () => {
66
+ const manager = new BaselineManager({});
67
+ const baseline = manager.createBaseline(mockResult, {});
68
+
69
+ expect(baseline.status).toBe(200);
70
+ expect(baseline.body).toEqual(mockResult.body);
71
+ expect(baseline.headers).toBeDefined();
72
+ expect(baseline.hash).toBeDefined();
73
+ expect(baseline.capturedAt).toBeDefined();
74
+ expect(baseline.timing).toBeUndefined();
75
+ });
76
+
77
+ test('should include timing when configured', () => {
78
+ const manager = new BaselineManager({});
79
+ const baseline = manager.createBaseline(mockResult, { includeTimings: true });
80
+
81
+ expect(baseline.timing).toBe(150);
82
+ });
83
+
84
+ test('should normalize headers (lowercase, sorted)', () => {
85
+ const manager = new BaselineManager({});
86
+ const baseline = manager.createBaseline(mockResult, {});
87
+
88
+ expect(baseline.headers).toEqual({
89
+ 'content-type': 'application/json',
90
+ 'x-request-id': 'abc123',
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('hash', () => {
96
+ test('should generate consistent hashes', () => {
97
+ const manager = new BaselineManager({});
98
+ const content = { id: 1, name: 'test' };
99
+
100
+ const hash1 = manager.hash(content);
101
+ const hash2 = manager.hash(content);
102
+
103
+ expect(hash1).toBe(hash2);
104
+ });
105
+
106
+ test('should generate different hashes for different content', () => {
107
+ const manager = new BaselineManager({});
108
+
109
+ const hash1 = manager.hash({ id: 1 });
110
+ const hash2 = manager.hash({ id: 2 });
111
+
112
+ expect(hash1).not.toBe(hash2);
113
+ });
114
+
115
+ test('should produce 8 character hash', () => {
116
+ const manager = new BaselineManager({});
117
+ const hash = manager.hash({ data: 'test' });
118
+
119
+ expect(hash).toHaveLength(8);
120
+ });
121
+ });
122
+
123
+ describe('mergeConfig', () => {
124
+ test('should return null if not enabled', () => {
125
+ const config = BaselineManager.mergeConfig({}, undefined);
126
+ expect(config).toBeNull();
127
+ });
128
+
129
+ test('should return null if explicitly disabled', () => {
130
+ const config = BaselineManager.mergeConfig({}, { enabled: false });
131
+ expect(config).toBeNull();
132
+ });
133
+
134
+ test('should handle boolean true', () => {
135
+ const config = BaselineManager.mergeConfig({}, true);
136
+ expect(config).toEqual({
137
+ enabled: true,
138
+ exclude: [],
139
+ match: {},
140
+ includeTimings: false,
141
+ });
142
+ });
143
+
144
+ test('should merge global and request excludes', () => {
145
+ const config = BaselineManager.mergeConfig(
146
+ { exclude: ['*.timestamp'] },
147
+ { enabled: true, exclude: ['body.id'] },
148
+ );
149
+ expect(config?.exclude).toEqual(['*.timestamp', 'body.id']);
150
+ });
151
+
152
+ test('should merge match rules', () => {
153
+ const config = BaselineManager.mergeConfig(
154
+ { match: { 'body.id': '*' } },
155
+ { enabled: true, match: { 'body.token': 'regex:^[a-z]+$' } },
156
+ );
157
+ expect(config?.match).toEqual({
158
+ 'body.id': '*',
159
+ 'body.token': 'regex:^[a-z]+$',
160
+ });
161
+ });
162
+
163
+ test('should use global enabled', () => {
164
+ const config = BaselineManager.mergeConfig({ enabled: true }, undefined);
165
+ expect(config?.enabled).toBe(true);
166
+ });
167
+
168
+ test('should inherit includeTimings from global', () => {
169
+ const config = BaselineManager.mergeConfig({ enabled: true, includeTimings: true }, true);
170
+ expect(config?.includeTimings).toBe(true);
171
+ });
172
+
173
+ test('should allow request to override includeTimings', () => {
174
+ const config = BaselineManager.mergeConfig(
175
+ { enabled: true, includeTimings: false },
176
+ { enabled: true, includeTimings: true },
177
+ );
178
+ expect(config?.includeTimings).toBe(true);
179
+ });
180
+ });
181
+ });