@curl-runner/cli 1.6.0 → 1.8.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.6.0",
3
+ "version": "1.8.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
@@ -84,6 +84,13 @@ class CurlRunnerCLI {
84
84
  process.env.CURL_RUNNER_CONTINUE_ON_ERROR.toLowerCase() === 'true';
85
85
  }
86
86
 
87
+ if (process.env.CURL_RUNNER_MAX_CONCURRENCY) {
88
+ const maxConcurrency = Number.parseInt(process.env.CURL_RUNNER_MAX_CONCURRENCY, 10);
89
+ if (maxConcurrency > 0) {
90
+ envConfig.maxConcurrency = maxConcurrency;
91
+ }
92
+ }
93
+
87
94
  if (process.env.CURL_RUNNER_OUTPUT_FORMAT) {
88
95
  const format = process.env.CURL_RUNNER_OUTPUT_FORMAT;
89
96
  if (['json', 'pretty', 'raw'].includes(format)) {
@@ -200,6 +207,9 @@ class CurlRunnerCLI {
200
207
  if (options.execution) {
201
208
  globalConfig.execution = options.execution as 'sequential' | 'parallel';
202
209
  }
210
+ if (options.maxConcurrent !== undefined) {
211
+ globalConfig.maxConcurrency = options.maxConcurrent as number;
212
+ }
203
213
  if (options.continueOnError !== undefined) {
204
214
  globalConfig.continueOnError = options.continueOnError;
205
215
  }
@@ -367,6 +377,11 @@ class CurlRunnerCLI {
367
377
  options.retries = Number.parseInt(nextArg, 10);
368
378
  } else if (key === 'retry-delay') {
369
379
  options.retryDelay = Number.parseInt(nextArg, 10);
380
+ } else if (key === 'max-concurrent') {
381
+ const maxConcurrent = Number.parseInt(nextArg, 10);
382
+ if (maxConcurrent > 0) {
383
+ options.maxConcurrent = maxConcurrent;
384
+ }
370
385
  } else if (key === 'fail-on') {
371
386
  options.failOn = Number.parseInt(nextArg, 10);
372
387
  } else if (key === 'fail-on-percentage') {
@@ -603,6 +618,7 @@ ${this.logger.color('OPTIONS:', 'yellow')}
603
618
  -v, --verbose Enable verbose output
604
619
  -q, --quiet Suppress non-error output
605
620
  -p, --execution parallel Execute requests in parallel
621
+ --max-concurrent <n> Limit concurrent requests in parallel mode
606
622
  -c, --continue-on-error Continue execution on errors
607
623
  -o, --output <file> Save results to file
608
624
  --all Find all YAML files recursively
@@ -638,6 +654,9 @@ ${this.logger.color('EXAMPLES:', 'yellow')}
638
654
  # Run all files recursively in parallel
639
655
  curl-runner --all -p
640
656
 
657
+ # Run in parallel with max 5 concurrent requests
658
+ curl-runner -p --max-concurrent 5 tests.yaml
659
+
641
660
  # Run directory recursively
642
661
  curl-runner --all examples/
643
662
 
@@ -0,0 +1,139 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { GlobalConfig, RequestConfig } from '../types/config';
3
+
4
+ /**
5
+ * Simulates the chunked execution logic from executeParallel
6
+ * Returns the chunks that would be created based on maxConcurrency
7
+ */
8
+ function getExecutionChunks(
9
+ requests: RequestConfig[],
10
+ maxConcurrency: number | undefined,
11
+ ): RequestConfig[][] {
12
+ if (!maxConcurrency || maxConcurrency >= requests.length) {
13
+ return [requests]; // All requests in a single batch
14
+ }
15
+
16
+ const chunks: RequestConfig[][] = [];
17
+ for (let i = 0; i < requests.length; i += maxConcurrency) {
18
+ chunks.push(requests.slice(i, i + maxConcurrency));
19
+ }
20
+ return chunks;
21
+ }
22
+
23
+ /**
24
+ * Creates mock requests for testing
25
+ */
26
+ function createMockRequests(count: number): RequestConfig[] {
27
+ return Array.from({ length: count }, (_, i) => ({
28
+ url: `https://api.example.com/request/${i + 1}`,
29
+ method: 'GET' as const,
30
+ name: `Request ${i + 1}`,
31
+ }));
32
+ }
33
+
34
+ describe('maxConcurrency parallel execution', () => {
35
+ describe('chunk creation', () => {
36
+ test('should execute all requests at once when maxConcurrency is not set', () => {
37
+ const requests = createMockRequests(10);
38
+ const chunks = getExecutionChunks(requests, undefined);
39
+ expect(chunks).toHaveLength(1);
40
+ expect(chunks[0]).toHaveLength(10);
41
+ });
42
+
43
+ test('should execute all requests at once when maxConcurrency >= requests.length', () => {
44
+ const requests = createMockRequests(5);
45
+ const chunks = getExecutionChunks(requests, 10);
46
+ expect(chunks).toHaveLength(1);
47
+ expect(chunks[0]).toHaveLength(5);
48
+ });
49
+
50
+ test('should create correct chunks when maxConcurrency is 1', () => {
51
+ const requests = createMockRequests(3);
52
+ const chunks = getExecutionChunks(requests, 1);
53
+ expect(chunks).toHaveLength(3);
54
+ expect(chunks[0]).toHaveLength(1);
55
+ expect(chunks[1]).toHaveLength(1);
56
+ expect(chunks[2]).toHaveLength(1);
57
+ });
58
+
59
+ test('should create correct chunks when maxConcurrency is 2', () => {
60
+ const requests = createMockRequests(5);
61
+ const chunks = getExecutionChunks(requests, 2);
62
+ expect(chunks).toHaveLength(3);
63
+ expect(chunks[0]).toHaveLength(2);
64
+ expect(chunks[1]).toHaveLength(2);
65
+ expect(chunks[2]).toHaveLength(1);
66
+ });
67
+
68
+ test('should create correct chunks when maxConcurrency is 3', () => {
69
+ const requests = createMockRequests(10);
70
+ const chunks = getExecutionChunks(requests, 3);
71
+ expect(chunks).toHaveLength(4);
72
+ expect(chunks[0]).toHaveLength(3);
73
+ expect(chunks[1]).toHaveLength(3);
74
+ expect(chunks[2]).toHaveLength(3);
75
+ expect(chunks[3]).toHaveLength(1);
76
+ });
77
+
78
+ test('should handle edge case with maxConcurrency equal to requests length', () => {
79
+ const requests = createMockRequests(5);
80
+ const chunks = getExecutionChunks(requests, 5);
81
+ expect(chunks).toHaveLength(1);
82
+ expect(chunks[0]).toHaveLength(5);
83
+ });
84
+
85
+ test('should preserve request order across chunks', () => {
86
+ const requests = createMockRequests(6);
87
+ const chunks = getExecutionChunks(requests, 2);
88
+
89
+ // Verify order is preserved
90
+ expect(chunks[0][0].name).toBe('Request 1');
91
+ expect(chunks[0][1].name).toBe('Request 2');
92
+ expect(chunks[1][0].name).toBe('Request 3');
93
+ expect(chunks[1][1].name).toBe('Request 4');
94
+ expect(chunks[2][0].name).toBe('Request 5');
95
+ expect(chunks[2][1].name).toBe('Request 6');
96
+ });
97
+ });
98
+
99
+ describe('GlobalConfig maxConcurrency validation', () => {
100
+ test('should accept valid maxConcurrency values', () => {
101
+ const config1: GlobalConfig = { execution: 'parallel', maxConcurrency: 1 };
102
+ expect(config1.maxConcurrency).toBe(1);
103
+
104
+ const config2: GlobalConfig = { execution: 'parallel', maxConcurrency: 5 };
105
+ expect(config2.maxConcurrency).toBe(5);
106
+
107
+ const config3: GlobalConfig = { execution: 'parallel', maxConcurrency: 100 };
108
+ expect(config3.maxConcurrency).toBe(100);
109
+ });
110
+
111
+ test('should allow undefined maxConcurrency', () => {
112
+ const config: GlobalConfig = { execution: 'parallel' };
113
+ expect(config.maxConcurrency).toBeUndefined();
114
+ });
115
+ });
116
+
117
+ describe('integration with other settings', () => {
118
+ test('maxConcurrency should coexist with continueOnError', () => {
119
+ const config: GlobalConfig = {
120
+ execution: 'parallel',
121
+ maxConcurrency: 5,
122
+ continueOnError: true,
123
+ };
124
+ expect(config.maxConcurrency).toBe(5);
125
+ expect(config.continueOnError).toBe(true);
126
+ });
127
+
128
+ test('maxConcurrency should coexist with CI settings', () => {
129
+ const config: GlobalConfig = {
130
+ execution: 'parallel',
131
+ maxConcurrency: 3,
132
+ ci: { strictExit: true, failOn: 2 },
133
+ };
134
+ expect(config.maxConcurrency).toBe(3);
135
+ expect(config.ci?.strictExit).toBe(true);
136
+ expect(config.ci?.failOn).toBe(2);
137
+ });
138
+ });
139
+ });
@@ -101,7 +101,9 @@ export class RequestExecutor {
101
101
  if (attempt > 0) {
102
102
  requestLogger.logRetry(attempt, maxAttempts - 1);
103
103
  if (config.retry?.delay) {
104
- await Bun.sleep(config.retry.delay);
104
+ const backoff = config.retry.backoff ?? 1;
105
+ const delay = config.retry.delay * Math.pow(backoff, attempt - 1);
106
+ await Bun.sleep(delay);
105
107
  }
106
108
  }
107
109
 
@@ -538,10 +540,32 @@ export class RequestExecutor {
538
540
 
539
541
  async executeParallel(requests: RequestConfig[]): Promise<ExecutionSummary> {
540
542
  const startTime = performance.now();
543
+ const maxConcurrency = this.globalConfig.maxConcurrency;
541
544
 
542
- const promises = requests.map((request, index) => this.executeRequest(request, index + 1));
545
+ // If no concurrency limit, execute all requests simultaneously
546
+ if (!maxConcurrency || maxConcurrency >= requests.length) {
547
+ const promises = requests.map((request, index) => this.executeRequest(request, index + 1));
548
+ const results = await Promise.all(promises);
549
+ return this.createSummary(results, performance.now() - startTime);
550
+ }
543
551
 
544
- const results = await Promise.all(promises);
552
+ // Execute in chunks with limited concurrency
553
+ const results: ExecutionResult[] = [];
554
+ for (let i = 0; i < requests.length; i += maxConcurrency) {
555
+ const chunk = requests.slice(i, i + maxConcurrency);
556
+ const chunkPromises = chunk.map((request, chunkIndex) =>
557
+ this.executeRequest(request, i + chunkIndex + 1),
558
+ );
559
+ const chunkResults = await Promise.all(chunkPromises);
560
+ results.push(...chunkResults);
561
+
562
+ // Check if we should stop on error
563
+ const hasError = chunkResults.some((r) => !r.success);
564
+ if (hasError && !this.globalConfig.continueOnError) {
565
+ this.logger.logError('Stopping execution due to error');
566
+ break;
567
+ }
568
+ }
545
569
 
546
570
  return this.createSummary(results, performance.now() - startTime);
547
571
  }
@@ -92,6 +92,9 @@ export interface RequestConfig {
92
92
  retry?: {
93
93
  count: number;
94
94
  delay?: number;
95
+ /** Exponential backoff multiplier. Default is 1 (no backoff).
96
+ * Example: with delay=1000 and backoff=2, delays are: 1000ms, 2000ms, 4000ms, 8000ms... */
97
+ backoff?: number;
95
98
  };
96
99
  variables?: Record<string, string>;
97
100
  /**
@@ -159,6 +162,12 @@ export interface CIExitConfig {
159
162
 
160
163
  export interface GlobalConfig {
161
164
  execution?: 'sequential' | 'parallel';
165
+ /**
166
+ * Maximum number of concurrent requests when using parallel execution.
167
+ * If not set, all requests will execute simultaneously.
168
+ * Useful for avoiding rate limiting or overwhelming target servers.
169
+ */
170
+ maxConcurrency?: number;
162
171
  continueOnError?: boolean;
163
172
  /**
164
173
  * CI/CD exit code configuration.