@curl-runner/cli 1.5.0 → 1.7.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 +1 -1
- package/src/cli.ts +19 -0
- package/src/executor/max-concurrency.test.ts +139 -0
- package/src/executor/request-executor.ts +24 -2
- package/src/parser/yaml.test.ts +81 -0
- package/src/parser/yaml.ts +19 -2
- package/src/types/config.ts +6 -0
package/package.json
CHANGED
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
|
+
});
|
|
@@ -538,10 +538,32 @@ export class RequestExecutor {
|
|
|
538
538
|
|
|
539
539
|
async executeParallel(requests: RequestConfig[]): Promise<ExecutionSummary> {
|
|
540
540
|
const startTime = performance.now();
|
|
541
|
+
const maxConcurrency = this.globalConfig.maxConcurrency;
|
|
541
542
|
|
|
542
|
-
|
|
543
|
+
// If no concurrency limit, execute all requests simultaneously
|
|
544
|
+
if (!maxConcurrency || maxConcurrency >= requests.length) {
|
|
545
|
+
const promises = requests.map((request, index) => this.executeRequest(request, index + 1));
|
|
546
|
+
const results = await Promise.all(promises);
|
|
547
|
+
return this.createSummary(results, performance.now() - startTime);
|
|
548
|
+
}
|
|
543
549
|
|
|
544
|
-
|
|
550
|
+
// Execute in chunks with limited concurrency
|
|
551
|
+
const results: ExecutionResult[] = [];
|
|
552
|
+
for (let i = 0; i < requests.length; i += maxConcurrency) {
|
|
553
|
+
const chunk = requests.slice(i, i + maxConcurrency);
|
|
554
|
+
const chunkPromises = chunk.map((request, chunkIndex) =>
|
|
555
|
+
this.executeRequest(request, i + chunkIndex + 1),
|
|
556
|
+
);
|
|
557
|
+
const chunkResults = await Promise.all(chunkPromises);
|
|
558
|
+
results.push(...chunkResults);
|
|
559
|
+
|
|
560
|
+
// Check if we should stop on error
|
|
561
|
+
const hasError = chunkResults.some((r) => !r.success);
|
|
562
|
+
if (hasError && !this.globalConfig.continueOnError) {
|
|
563
|
+
this.logger.logError('Stopping execution due to error');
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
545
567
|
|
|
546
568
|
return this.createSummary(results, performance.now() - startTime);
|
|
547
569
|
}
|
package/src/parser/yaml.test.ts
CHANGED
|
@@ -175,6 +175,84 @@ describe('YamlParser.resolveVariable', () => {
|
|
|
175
175
|
});
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
describe('YamlParser string transforms', () => {
|
|
179
|
+
test('should transform variable to uppercase with :upper', () => {
|
|
180
|
+
const variables = { ENV: 'production' };
|
|
181
|
+
const result = YamlParser.resolveVariable('ENV:upper', variables, {});
|
|
182
|
+
expect(result).toBe('PRODUCTION');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('should transform variable to lowercase with :lower', () => {
|
|
186
|
+
const variables = { RESOURCE: 'USERS' };
|
|
187
|
+
const result = YamlParser.resolveVariable('RESOURCE:lower', variables, {});
|
|
188
|
+
expect(result).toBe('users');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('should return null for transform on missing variable', () => {
|
|
192
|
+
const result = YamlParser.resolveVariable('MISSING:upper', {}, {});
|
|
193
|
+
expect(result).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('should work with interpolateVariables for :upper transform', () => {
|
|
197
|
+
const obj = {
|
|
198
|
+
headers: {
|
|
199
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing string transform
|
|
200
|
+
'X-Environment': '${ENV:upper}',
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
const variables = { ENV: 'production' };
|
|
204
|
+
const result = YamlParser.interpolateVariables(obj, variables);
|
|
205
|
+
expect(result).toEqual({
|
|
206
|
+
headers: {
|
|
207
|
+
'X-Environment': 'PRODUCTION',
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('should work with interpolateVariables for :lower transform', () => {
|
|
213
|
+
const obj = {
|
|
214
|
+
headers: {
|
|
215
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing string transform
|
|
216
|
+
'X-Resource': '${RESOURCE:lower}',
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
const variables = { RESOURCE: 'USERS' };
|
|
220
|
+
const result = YamlParser.interpolateVariables(obj, variables);
|
|
221
|
+
expect(result).toEqual({
|
|
222
|
+
headers: {
|
|
223
|
+
'X-Resource': 'users',
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('should mix transforms with regular variables', () => {
|
|
229
|
+
const obj = {
|
|
230
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing string transform
|
|
231
|
+
url: '${BASE_URL}/${RESOURCE:lower}',
|
|
232
|
+
headers: {
|
|
233
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing string transform
|
|
234
|
+
'X-Environment': '${ENV:upper}',
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
const variables = { BASE_URL: 'https://api.example.com', RESOURCE: 'Users', ENV: 'staging' };
|
|
238
|
+
const result = YamlParser.interpolateVariables(obj, variables);
|
|
239
|
+
expect(result).toEqual({
|
|
240
|
+
url: 'https://api.example.com/users',
|
|
241
|
+
headers: {
|
|
242
|
+
'X-Environment': 'STAGING',
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('should keep unresolved transforms as-is', () => {
|
|
248
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing string transform
|
|
249
|
+
const obj = { value: '${MISSING:upper}' };
|
|
250
|
+
const result = YamlParser.interpolateVariables(obj, {});
|
|
251
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing string transform
|
|
252
|
+
expect(result).toEqual({ value: '${MISSING:upper}' });
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
178
256
|
describe('YamlParser.resolveVariable with default values', () => {
|
|
179
257
|
test('should use default value when variable is not set', () => {
|
|
180
258
|
const result = YamlParser.resolveVariable('API_TIMEOUT:5000', {}, {});
|
|
@@ -189,17 +267,20 @@ describe('YamlParser.resolveVariable with default values', () => {
|
|
|
189
267
|
|
|
190
268
|
test('should handle nested default with first variable set', () => {
|
|
191
269
|
const variables = { DATABASE_HOST: 'prod-db.example.com' };
|
|
270
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing variable interpolation
|
|
192
271
|
const result = YamlParser.resolveVariable('DATABASE_HOST:${DB_HOST:localhost}', variables, {});
|
|
193
272
|
expect(result).toBe('prod-db.example.com');
|
|
194
273
|
});
|
|
195
274
|
|
|
196
275
|
test('should handle nested default with second variable set', () => {
|
|
197
276
|
const variables = { DB_HOST: 'staging-db.example.com' };
|
|
277
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing variable interpolation
|
|
198
278
|
const result = YamlParser.resolveVariable('DATABASE_HOST:${DB_HOST:localhost}', variables, {});
|
|
199
279
|
expect(result).toBe('staging-db.example.com');
|
|
200
280
|
});
|
|
201
281
|
|
|
202
282
|
test('should use final fallback when no variables are set', () => {
|
|
283
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing variable interpolation
|
|
203
284
|
const result = YamlParser.resolveVariable('DATABASE_HOST:${DB_HOST:localhost}', {}, {});
|
|
204
285
|
expect(result).toBe('localhost');
|
|
205
286
|
});
|
package/src/parser/yaml.ts
CHANGED
|
@@ -39,7 +39,11 @@ export class YamlParser {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// Check if it's a single variable that spans the entire string
|
|
42
|
-
if (
|
|
42
|
+
if (
|
|
43
|
+
extractedVars.length === 1 &&
|
|
44
|
+
extractedVars[0].start === 0 &&
|
|
45
|
+
extractedVars[0].end === obj.length
|
|
46
|
+
) {
|
|
43
47
|
const varName = extractedVars[0].name;
|
|
44
48
|
const resolvedValue = YamlParser.resolveVariable(varName, variables, storeContext);
|
|
45
49
|
return resolvedValue !== null ? resolvedValue : obj;
|
|
@@ -75,7 +79,7 @@ export class YamlParser {
|
|
|
75
79
|
|
|
76
80
|
/**
|
|
77
81
|
* Resolves a single variable reference.
|
|
78
|
-
* Priority: store context > dynamic variables > static variables > default values
|
|
82
|
+
* Priority: store context > string transforms > dynamic variables > static variables > default values
|
|
79
83
|
*/
|
|
80
84
|
static resolveVariable(
|
|
81
85
|
varName: string,
|
|
@@ -91,6 +95,19 @@ export class YamlParser {
|
|
|
91
95
|
return null; // Store variable not found, return null to keep original
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
// Check for string transforms: ${VAR:upper} or ${VAR:lower}
|
|
99
|
+
const transformMatch = varName.match(/^([^:]+):(upper|lower)$/);
|
|
100
|
+
if (transformMatch) {
|
|
101
|
+
const baseVarName = transformMatch[1];
|
|
102
|
+
const transform = transformMatch[2];
|
|
103
|
+
const baseValue = variables[baseVarName] || process.env[baseVarName];
|
|
104
|
+
|
|
105
|
+
if (baseValue) {
|
|
106
|
+
return transform === 'upper' ? baseValue.toUpperCase() : baseValue.toLowerCase();
|
|
107
|
+
}
|
|
108
|
+
return null; // Base variable not found
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
// Check for default value syntax: ${VAR:default}
|
|
95
112
|
// Must check before dynamic variables to properly handle defaults
|
|
96
113
|
const colonIndex = varName.indexOf(':');
|
package/src/types/config.ts
CHANGED
|
@@ -159,6 +159,12 @@ export interface CIExitConfig {
|
|
|
159
159
|
|
|
160
160
|
export interface GlobalConfig {
|
|
161
161
|
execution?: 'sequential' | 'parallel';
|
|
162
|
+
/**
|
|
163
|
+
* Maximum number of concurrent requests when using parallel execution.
|
|
164
|
+
* If not set, all requests will execute simultaneously.
|
|
165
|
+
* Useful for avoiding rate limiting or overwhelming target servers.
|
|
166
|
+
*/
|
|
167
|
+
maxConcurrency?: number;
|
|
162
168
|
continueOnError?: boolean;
|
|
163
169
|
/**
|
|
164
170
|
* CI/CD exit code configuration.
|