@curl-runner/cli 1.0.1
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/README.md +518 -0
- package/package.json +43 -0
- package/src/cli.ts +562 -0
- package/src/executor/request-executor.ts +490 -0
- package/src/parser/yaml.ts +99 -0
- package/src/types/config.ts +106 -0
- package/src/utils/curl-builder.ts +152 -0
- package/src/utils/logger.ts +501 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExecutionResult,
|
|
3
|
+
ExecutionSummary,
|
|
4
|
+
GlobalConfig,
|
|
5
|
+
JsonValue,
|
|
6
|
+
RequestConfig,
|
|
7
|
+
} from '../types/config';
|
|
8
|
+
import { CurlBuilder } from '../utils/curl-builder';
|
|
9
|
+
import { Logger } from '../utils/logger';
|
|
10
|
+
|
|
11
|
+
export class RequestExecutor {
|
|
12
|
+
private logger: Logger;
|
|
13
|
+
private globalConfig: GlobalConfig;
|
|
14
|
+
|
|
15
|
+
constructor(globalConfig: GlobalConfig = {}) {
|
|
16
|
+
this.globalConfig = globalConfig;
|
|
17
|
+
this.logger = new Logger(globalConfig.output);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private mergeOutputConfig(config: RequestConfig): GlobalConfig['output'] {
|
|
21
|
+
// Precedence: Individual YAML file > curl-runner.yaml > CLI options > env vars > defaults
|
|
22
|
+
return {
|
|
23
|
+
...this.globalConfig.output, // CLI options, env vars, and defaults (lowest priority)
|
|
24
|
+
...config.sourceOutputConfig, // Individual file's output config (highest priority)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async executeRequest(config: RequestConfig, index: number = 0): Promise<ExecutionResult> {
|
|
29
|
+
const startTime = performance.now();
|
|
30
|
+
|
|
31
|
+
// Create per-request logger with merged output configuration
|
|
32
|
+
const outputConfig = this.mergeOutputConfig(config);
|
|
33
|
+
const requestLogger = new Logger(outputConfig);
|
|
34
|
+
|
|
35
|
+
requestLogger.logRequestStart(config, index);
|
|
36
|
+
|
|
37
|
+
const command = CurlBuilder.buildCommand(config);
|
|
38
|
+
requestLogger.logCommand(command);
|
|
39
|
+
|
|
40
|
+
let attempt = 0;
|
|
41
|
+
let lastError: string | undefined;
|
|
42
|
+
const maxAttempts = (config.retry?.count || 0) + 1;
|
|
43
|
+
|
|
44
|
+
while (attempt < maxAttempts) {
|
|
45
|
+
if (attempt > 0) {
|
|
46
|
+
requestLogger.logRetry(attempt, maxAttempts - 1);
|
|
47
|
+
if (config.retry?.delay) {
|
|
48
|
+
await Bun.sleep(config.retry.delay);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const result = await CurlBuilder.executeCurl(command);
|
|
53
|
+
|
|
54
|
+
if (result.success) {
|
|
55
|
+
let body = result.body;
|
|
56
|
+
try {
|
|
57
|
+
if (
|
|
58
|
+
result.headers?.['content-type']?.includes('application/json') ||
|
|
59
|
+
(body && (body.trim().startsWith('{') || body.trim().startsWith('[')))
|
|
60
|
+
) {
|
|
61
|
+
body = JSON.parse(body);
|
|
62
|
+
}
|
|
63
|
+
} catch (_e) {}
|
|
64
|
+
|
|
65
|
+
const executionResult: ExecutionResult = {
|
|
66
|
+
request: config,
|
|
67
|
+
success: true,
|
|
68
|
+
status: result.status,
|
|
69
|
+
headers: result.headers,
|
|
70
|
+
body,
|
|
71
|
+
metrics: {
|
|
72
|
+
...result.metrics,
|
|
73
|
+
duration: performance.now() - startTime,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (config.expect) {
|
|
78
|
+
const validationResult = this.validateResponse(executionResult, config.expect);
|
|
79
|
+
if (!validationResult.success) {
|
|
80
|
+
executionResult.success = false;
|
|
81
|
+
executionResult.error = validationResult.error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
requestLogger.logRequestComplete(executionResult);
|
|
86
|
+
return executionResult;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
lastError = result.error;
|
|
90
|
+
attempt++;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const failedResult: ExecutionResult = {
|
|
94
|
+
request: config,
|
|
95
|
+
success: false,
|
|
96
|
+
error: lastError,
|
|
97
|
+
metrics: {
|
|
98
|
+
duration: performance.now() - startTime,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
requestLogger.logRequestComplete(failedResult);
|
|
103
|
+
return failedResult;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private validateResponse(
|
|
107
|
+
result: ExecutionResult,
|
|
108
|
+
expect: RequestConfig['expect'],
|
|
109
|
+
): { success: boolean; error?: string } {
|
|
110
|
+
if (!expect) {
|
|
111
|
+
return { success: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const errors: string[] = [];
|
|
115
|
+
|
|
116
|
+
// Validate status
|
|
117
|
+
if (expect.status !== undefined) {
|
|
118
|
+
const expectedStatuses = Array.isArray(expect.status) ? expect.status : [expect.status];
|
|
119
|
+
if (!expectedStatuses.includes(result.status || 0)) {
|
|
120
|
+
errors.push(`Expected status ${expectedStatuses.join(' or ')}, got ${result.status}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Validate headers
|
|
125
|
+
if (expect.headers) {
|
|
126
|
+
for (const [key, value] of Object.entries(expect.headers)) {
|
|
127
|
+
const actualValue = result.headers?.[key] || result.headers?.[key.toLowerCase()];
|
|
128
|
+
if (actualValue !== value) {
|
|
129
|
+
errors.push(`Expected header ${key}="${value}", got "${actualValue}"`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Validate body
|
|
135
|
+
if (expect.body !== undefined) {
|
|
136
|
+
const bodyErrors = this.validateBodyProperties(result.body, expect.body, '');
|
|
137
|
+
if (bodyErrors.length > 0) {
|
|
138
|
+
errors.push(...bodyErrors);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Validate response time
|
|
143
|
+
if (expect.responseTime !== undefined && result.metrics) {
|
|
144
|
+
const responseTimeMs = result.metrics.duration;
|
|
145
|
+
if (!this.validateRangePattern(responseTimeMs, expect.responseTime)) {
|
|
146
|
+
errors.push(
|
|
147
|
+
`Expected response time to match ${expect.responseTime}ms, got ${responseTimeMs.toFixed(2)}ms`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const hasValidationErrors = errors.length > 0;
|
|
153
|
+
|
|
154
|
+
// Handle failure expectation logic
|
|
155
|
+
if (expect.failure === true) {
|
|
156
|
+
// We expect this request to fail (negative testing)
|
|
157
|
+
// Success means: validations pass AND status indicates error (4xx/5xx)
|
|
158
|
+
if (hasValidationErrors) {
|
|
159
|
+
return { success: false, error: errors.join('; ') };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if status indicates an error
|
|
163
|
+
const status = result.status || 0;
|
|
164
|
+
if (status >= 400) {
|
|
165
|
+
// Status indicates error and validations passed - SUCCESS for negative testing
|
|
166
|
+
return { success: true };
|
|
167
|
+
} else {
|
|
168
|
+
// Expected failure but got success status - FAILURE
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
error: `Expected request to fail (4xx/5xx) but got status ${status}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
// Normal case: expect success (validations should pass)
|
|
176
|
+
if (hasValidationErrors) {
|
|
177
|
+
return { success: false, error: errors.join('; ') };
|
|
178
|
+
} else {
|
|
179
|
+
return { success: true };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private validateBodyProperties(
|
|
185
|
+
actualBody: JsonValue,
|
|
186
|
+
expectedBody: JsonValue,
|
|
187
|
+
path: string,
|
|
188
|
+
): string[] {
|
|
189
|
+
const errors: string[] = [];
|
|
190
|
+
|
|
191
|
+
if (typeof expectedBody !== 'object' || expectedBody === null) {
|
|
192
|
+
// Advanced value validation
|
|
193
|
+
const validationResult = this.validateValue(actualBody, expectedBody, path || 'body');
|
|
194
|
+
if (!validationResult.isValid) {
|
|
195
|
+
errors.push(validationResult.error!);
|
|
196
|
+
}
|
|
197
|
+
return errors;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Array validation
|
|
201
|
+
if (Array.isArray(expectedBody)) {
|
|
202
|
+
const validationResult = this.validateValue(actualBody, expectedBody, path || 'body');
|
|
203
|
+
if (!validationResult.isValid) {
|
|
204
|
+
errors.push(validationResult.error!);
|
|
205
|
+
}
|
|
206
|
+
return errors;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Object property comparison with array selector support
|
|
210
|
+
for (const [key, expectedValue] of Object.entries(expectedBody)) {
|
|
211
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
212
|
+
let actualValue: JsonValue;
|
|
213
|
+
|
|
214
|
+
// Handle array selectors like [0], [-1], *, slice(0,3)
|
|
215
|
+
if (Array.isArray(actualBody) && this.isArraySelector(key)) {
|
|
216
|
+
actualValue = this.getArrayValue(actualBody, key);
|
|
217
|
+
} else {
|
|
218
|
+
actualValue = actualBody?.[key];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (
|
|
222
|
+
typeof expectedValue === 'object' &&
|
|
223
|
+
expectedValue !== null &&
|
|
224
|
+
!Array.isArray(expectedValue)
|
|
225
|
+
) {
|
|
226
|
+
// Recursive validation for nested objects
|
|
227
|
+
const nestedErrors = this.validateBodyProperties(actualValue, expectedValue, currentPath);
|
|
228
|
+
errors.push(...nestedErrors);
|
|
229
|
+
} else {
|
|
230
|
+
// Advanced value validation
|
|
231
|
+
const validationResult = this.validateValue(actualValue, expectedValue, currentPath);
|
|
232
|
+
if (!validationResult.isValid) {
|
|
233
|
+
errors.push(validationResult.error!);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return errors;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private validateValue(
|
|
242
|
+
actualValue: JsonValue,
|
|
243
|
+
expectedValue: JsonValue,
|
|
244
|
+
path: string,
|
|
245
|
+
): { isValid: boolean; error?: string } {
|
|
246
|
+
// Wildcard validation - accept any value
|
|
247
|
+
if (expectedValue === '*') {
|
|
248
|
+
return { isValid: true };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Multiple acceptable values (array)
|
|
252
|
+
if (Array.isArray(expectedValue)) {
|
|
253
|
+
const isMatch = expectedValue.some((expected) => {
|
|
254
|
+
if (expected === '*') {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
if (typeof expected === 'string' && this.isRegexPattern(expected)) {
|
|
258
|
+
return this.validateRegexPattern(actualValue, expected);
|
|
259
|
+
}
|
|
260
|
+
if (typeof expected === 'string' && this.isRangePattern(expected)) {
|
|
261
|
+
return this.validateRangePattern(actualValue, expected);
|
|
262
|
+
}
|
|
263
|
+
return actualValue === expected;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (!isMatch) {
|
|
267
|
+
return {
|
|
268
|
+
isValid: false,
|
|
269
|
+
error: `Expected ${path} to match one of ${JSON.stringify(expectedValue)}, got ${JSON.stringify(actualValue)}`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return { isValid: true };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Regex pattern validation
|
|
276
|
+
if (typeof expectedValue === 'string' && this.isRegexPattern(expectedValue)) {
|
|
277
|
+
if (!this.validateRegexPattern(actualValue, expectedValue)) {
|
|
278
|
+
return {
|
|
279
|
+
isValid: false,
|
|
280
|
+
error: `Expected ${path} to match pattern ${expectedValue}, got ${JSON.stringify(actualValue)}`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return { isValid: true };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Numeric range validation
|
|
287
|
+
if (typeof expectedValue === 'string' && this.isRangePattern(expectedValue)) {
|
|
288
|
+
if (!this.validateRangePattern(actualValue, expectedValue)) {
|
|
289
|
+
return {
|
|
290
|
+
isValid: false,
|
|
291
|
+
error: `Expected ${path} to match range ${expectedValue}, got ${JSON.stringify(actualValue)}`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return { isValid: true };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Null handling
|
|
298
|
+
if (expectedValue === 'null' || expectedValue === null) {
|
|
299
|
+
if (actualValue !== null) {
|
|
300
|
+
return {
|
|
301
|
+
isValid: false,
|
|
302
|
+
error: `Expected ${path} to be null, got ${JSON.stringify(actualValue)}`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return { isValid: true };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Exact value comparison
|
|
309
|
+
if (actualValue !== expectedValue) {
|
|
310
|
+
return {
|
|
311
|
+
isValid: false,
|
|
312
|
+
error: `Expected ${path} to be ${JSON.stringify(expectedValue)}, got ${JSON.stringify(actualValue)}`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { isValid: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private isRegexPattern(pattern: string): boolean {
|
|
320
|
+
return (
|
|
321
|
+
pattern.startsWith('^') ||
|
|
322
|
+
pattern.endsWith('$') ||
|
|
323
|
+
pattern.includes('\\d') ||
|
|
324
|
+
pattern.includes('\\w') ||
|
|
325
|
+
pattern.includes('\\s') ||
|
|
326
|
+
pattern.includes('[') ||
|
|
327
|
+
pattern.includes('*') ||
|
|
328
|
+
pattern.includes('+') ||
|
|
329
|
+
pattern.includes('?')
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private validateRegexPattern(actualValue: JsonValue, pattern: string): boolean {
|
|
334
|
+
// Convert value to string for regex matching
|
|
335
|
+
const stringValue = String(actualValue);
|
|
336
|
+
try {
|
|
337
|
+
const regex = new RegExp(pattern);
|
|
338
|
+
return regex.test(stringValue);
|
|
339
|
+
} catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private isRangePattern(pattern: string): boolean {
|
|
345
|
+
// Only match explicit comparison operators, not simple number-dash-number patterns
|
|
346
|
+
// This prevents matching things like zip codes "92998-3874" as ranges
|
|
347
|
+
return /^(>=?|<=?|>|<)\s*[\d.-]+(\s*,\s*(>=?|<=?|>|<)\s*[\d.-]+)*$/.test(pattern);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private validateRangePattern(actualValue: JsonValue, pattern: string): boolean {
|
|
351
|
+
const numValue = Number(actualValue);
|
|
352
|
+
if (Number.isNaN(numValue)) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Handle range like "10 - 50" or "10-50"
|
|
357
|
+
const rangeMatch = pattern.match(/^([\d.-]+)\s*-\s*([\d.-]+)$/);
|
|
358
|
+
if (rangeMatch) {
|
|
359
|
+
const min = Number(rangeMatch[1]);
|
|
360
|
+
const max = Number(rangeMatch[2]);
|
|
361
|
+
return numValue >= min && numValue <= max;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Handle comma-separated conditions like ">= 0, <= 100"
|
|
365
|
+
const conditions = pattern.split(',').map((c) => c.trim());
|
|
366
|
+
return conditions.every((condition) => {
|
|
367
|
+
const match = condition.match(/^(>=?|<=?|>|<)\s*([\d.-]+)$/);
|
|
368
|
+
if (!match) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const operator = match[1];
|
|
373
|
+
const targetValue = Number(match[2]);
|
|
374
|
+
|
|
375
|
+
switch (operator) {
|
|
376
|
+
case '>':
|
|
377
|
+
return numValue > targetValue;
|
|
378
|
+
case '>=':
|
|
379
|
+
return numValue >= targetValue;
|
|
380
|
+
case '<':
|
|
381
|
+
return numValue < targetValue;
|
|
382
|
+
case '<=':
|
|
383
|
+
return numValue <= targetValue;
|
|
384
|
+
default:
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private isArraySelector(key: string): boolean {
|
|
391
|
+
return /^\[.*\]$/.test(key) || key === '*' || key.startsWith('slice(');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private getArrayValue(array: JsonValue[], selector: string): JsonValue {
|
|
395
|
+
if (selector === '*') {
|
|
396
|
+
return array; // Return entire array for * validation
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (selector.startsWith('[') && selector.endsWith(']')) {
|
|
400
|
+
const index = selector.slice(1, -1);
|
|
401
|
+
if (index === '*') {
|
|
402
|
+
return array;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const numIndex = Number(index);
|
|
406
|
+
if (!Number.isNaN(numIndex)) {
|
|
407
|
+
return numIndex >= 0 ? array[numIndex] : array[array.length + numIndex];
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (selector.startsWith('slice(')) {
|
|
412
|
+
const match = selector.match(/slice\((\d+)(?:,(\d+))?\)/);
|
|
413
|
+
if (match) {
|
|
414
|
+
const start = Number(match[1]);
|
|
415
|
+
const end = match[2] ? Number(match[2]) : undefined;
|
|
416
|
+
return array.slice(start, end);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async executeSequential(requests: RequestConfig[]): Promise<ExecutionSummary> {
|
|
424
|
+
const startTime = performance.now();
|
|
425
|
+
const results: ExecutionResult[] = [];
|
|
426
|
+
|
|
427
|
+
for (let i = 0; i < requests.length; i++) {
|
|
428
|
+
const result = await this.executeRequest(requests[i], i + 1);
|
|
429
|
+
results.push(result);
|
|
430
|
+
|
|
431
|
+
if (!result.success && !this.globalConfig.continueOnError) {
|
|
432
|
+
this.logger.logError('Stopping execution due to error');
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return this.createSummary(results, performance.now() - startTime);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async executeParallel(requests: RequestConfig[]): Promise<ExecutionSummary> {
|
|
441
|
+
const startTime = performance.now();
|
|
442
|
+
|
|
443
|
+
const promises = requests.map((request, index) => this.executeRequest(request, index + 1));
|
|
444
|
+
|
|
445
|
+
const results = await Promise.all(promises);
|
|
446
|
+
|
|
447
|
+
return this.createSummary(results, performance.now() - startTime);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async execute(requests: RequestConfig[]): Promise<ExecutionSummary> {
|
|
451
|
+
this.logger.logExecutionStart(requests.length, this.globalConfig.execution || 'sequential');
|
|
452
|
+
|
|
453
|
+
const summary =
|
|
454
|
+
this.globalConfig.execution === 'parallel'
|
|
455
|
+
? await this.executeParallel(requests)
|
|
456
|
+
: await this.executeSequential(requests);
|
|
457
|
+
|
|
458
|
+
this.logger.logSummary(summary);
|
|
459
|
+
|
|
460
|
+
if (this.globalConfig.output?.saveToFile) {
|
|
461
|
+
await this.saveSummaryToFile(summary);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return summary;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private createSummary(results: ExecutionResult[], duration: number): ExecutionSummary {
|
|
468
|
+
const successful = results.filter((r) => r.success).length;
|
|
469
|
+
const failed = results.filter((r) => !r.success).length;
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
total: results.length,
|
|
473
|
+
successful,
|
|
474
|
+
failed,
|
|
475
|
+
duration,
|
|
476
|
+
results,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async saveSummaryToFile(summary: ExecutionSummary): Promise<void> {
|
|
481
|
+
const file = this.globalConfig.output?.saveToFile;
|
|
482
|
+
if (!file) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const content = JSON.stringify(summary, null, 2);
|
|
487
|
+
await Bun.write(file, content);
|
|
488
|
+
this.logger.logInfo(`Results saved to ${file}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { YAML } from 'bun';
|
|
2
|
+
import type { RequestConfig, YamlFile } from '../types/config';
|
|
3
|
+
|
|
4
|
+
// Using class for organization, but could be refactored to functions
|
|
5
|
+
export class YamlParser {
|
|
6
|
+
static async parseFile(filepath: string): Promise<YamlFile> {
|
|
7
|
+
const file = Bun.file(filepath);
|
|
8
|
+
const content = await file.text();
|
|
9
|
+
return YAML.parse(content) as YamlFile;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static parse(content: string): YamlFile {
|
|
13
|
+
return YAML.parse(content) as YamlFile;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static interpolateVariables(obj: unknown, variables: Record<string, string>): unknown {
|
|
17
|
+
if (typeof obj === 'string') {
|
|
18
|
+
// Check if it's a single variable like ${VAR} (no other characters)
|
|
19
|
+
const singleVarMatch = obj.match(/^\$\{([^}]+)\}$/);
|
|
20
|
+
if (singleVarMatch) {
|
|
21
|
+
const varName = singleVarMatch[1];
|
|
22
|
+
const dynamicValue = YamlParser.resolveDynamicVariable(varName);
|
|
23
|
+
return dynamicValue !== null ? dynamicValue : variables[varName] || obj;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Handle multiple variables in the string using regex replacement
|
|
27
|
+
return obj.replace(/\$\{([^}]+)\}/g, (match, varName) => {
|
|
28
|
+
const dynamicValue = YamlParser.resolveDynamicVariable(varName);
|
|
29
|
+
return dynamicValue !== null ? dynamicValue : variables[varName] || match;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(obj)) {
|
|
34
|
+
return obj.map((item) => YamlParser.interpolateVariables(item, variables));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (obj && typeof obj === 'object') {
|
|
38
|
+
const result: Record<string, unknown> = {};
|
|
39
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
40
|
+
result[key] = YamlParser.interpolateVariables(value, variables);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return obj;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static resolveDynamicVariable(varName: string): string | null {
|
|
49
|
+
// UUID generation
|
|
50
|
+
if (varName === 'UUID') {
|
|
51
|
+
return crypto.randomUUID();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Current timestamp variations
|
|
55
|
+
if (varName === 'CURRENT_TIME' || varName === 'TIMESTAMP') {
|
|
56
|
+
return Date.now().toString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Date formatting - ${DATE:YYYY-MM-DD}
|
|
60
|
+
if (varName.startsWith('DATE:')) {
|
|
61
|
+
const format = varName.slice(5); // Remove 'DATE:'
|
|
62
|
+
return YamlParser.formatDate(new Date(), format);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Time formatting - ${TIME:HH:mm:ss}
|
|
66
|
+
if (varName.startsWith('TIME:')) {
|
|
67
|
+
const format = varName.slice(5); // Remove 'TIME:'
|
|
68
|
+
return YamlParser.formatTime(new Date(), format);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return null; // Not a dynamic variable
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static formatDate(date: Date, format: string): string {
|
|
75
|
+
const year = date.getFullYear();
|
|
76
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
77
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
78
|
+
|
|
79
|
+
return format.replace('YYYY', year.toString()).replace('MM', month).replace('DD', day);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static formatTime(date: Date, format: string): string {
|
|
83
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
84
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
85
|
+
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
86
|
+
|
|
87
|
+
return format.replace('HH', hours).replace('mm', minutes).replace('ss', seconds);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static mergeConfigs(base: Partial<RequestConfig>, override: RequestConfig): RequestConfig {
|
|
91
|
+
return {
|
|
92
|
+
...base,
|
|
93
|
+
...override,
|
|
94
|
+
headers: { ...base.headers, ...override.headers },
|
|
95
|
+
params: { ...base.params, ...override.params },
|
|
96
|
+
variables: { ...base.variables, ...override.variables },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export type JsonValue = string | number | boolean | null | JsonObject | JsonArray;
|
|
2
|
+
export interface JsonObject {
|
|
3
|
+
[key: string]: JsonValue;
|
|
4
|
+
}
|
|
5
|
+
export interface JsonArray extends Array<JsonValue> {}
|
|
6
|
+
|
|
7
|
+
export interface RequestConfig {
|
|
8
|
+
name?: string;
|
|
9
|
+
url: string;
|
|
10
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
params?: Record<string, string>;
|
|
13
|
+
sourceFile?: string; // Source YAML file for better output organization
|
|
14
|
+
body?: JsonValue;
|
|
15
|
+
timeout?: number;
|
|
16
|
+
followRedirects?: boolean;
|
|
17
|
+
maxRedirects?: number;
|
|
18
|
+
auth?: {
|
|
19
|
+
type: 'basic' | 'bearer';
|
|
20
|
+
username?: string;
|
|
21
|
+
password?: string;
|
|
22
|
+
token?: string;
|
|
23
|
+
};
|
|
24
|
+
proxy?: string;
|
|
25
|
+
insecure?: boolean;
|
|
26
|
+
output?: string;
|
|
27
|
+
retry?: {
|
|
28
|
+
count: number;
|
|
29
|
+
delay?: number;
|
|
30
|
+
};
|
|
31
|
+
variables?: Record<string, string>;
|
|
32
|
+
expect?: {
|
|
33
|
+
failure?: boolean; // If true, expect the request to fail (for negative testing)
|
|
34
|
+
status?: number | number[];
|
|
35
|
+
headers?: Record<string, string>;
|
|
36
|
+
body?: JsonValue;
|
|
37
|
+
responseTime?: string; // Response time validation like "< 1000", "> 500, < 2000"
|
|
38
|
+
};
|
|
39
|
+
sourceOutputConfig?: {
|
|
40
|
+
verbose?: boolean;
|
|
41
|
+
showHeaders?: boolean;
|
|
42
|
+
showBody?: boolean;
|
|
43
|
+
showMetrics?: boolean;
|
|
44
|
+
format?: 'json' | 'pretty' | 'raw';
|
|
45
|
+
prettyLevel?: 'minimal' | 'standard' | 'detailed';
|
|
46
|
+
saveToFile?: string;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CollectionConfig {
|
|
51
|
+
name: string;
|
|
52
|
+
description?: string;
|
|
53
|
+
variables?: Record<string, string>;
|
|
54
|
+
defaults?: Partial<RequestConfig>;
|
|
55
|
+
requests: RequestConfig[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GlobalConfig {
|
|
59
|
+
execution?: 'sequential' | 'parallel';
|
|
60
|
+
continueOnError?: boolean;
|
|
61
|
+
variables?: Record<string, string>;
|
|
62
|
+
output?: {
|
|
63
|
+
verbose?: boolean;
|
|
64
|
+
showHeaders?: boolean;
|
|
65
|
+
showBody?: boolean;
|
|
66
|
+
showMetrics?: boolean;
|
|
67
|
+
format?: 'json' | 'pretty' | 'raw';
|
|
68
|
+
prettyLevel?: 'minimal' | 'standard' | 'detailed';
|
|
69
|
+
saveToFile?: string;
|
|
70
|
+
};
|
|
71
|
+
defaults?: Partial<RequestConfig>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface YamlFile {
|
|
75
|
+
version?: string;
|
|
76
|
+
global?: GlobalConfig;
|
|
77
|
+
collection?: CollectionConfig;
|
|
78
|
+
requests?: RequestConfig[];
|
|
79
|
+
request?: RequestConfig;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ExecutionResult {
|
|
83
|
+
request: RequestConfig;
|
|
84
|
+
success: boolean;
|
|
85
|
+
status?: number;
|
|
86
|
+
headers?: Record<string, string>;
|
|
87
|
+
body?: JsonValue;
|
|
88
|
+
error?: string;
|
|
89
|
+
metrics?: {
|
|
90
|
+
duration: number;
|
|
91
|
+
size?: number;
|
|
92
|
+
dnsLookup?: number;
|
|
93
|
+
tcpConnection?: number;
|
|
94
|
+
tlsHandshake?: number;
|
|
95
|
+
firstByte?: number;
|
|
96
|
+
download?: number;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ExecutionSummary {
|
|
101
|
+
total: number;
|
|
102
|
+
successful: number;
|
|
103
|
+
failed: number;
|
|
104
|
+
duration: number;
|
|
105
|
+
results: ExecutionResult[];
|
|
106
|
+
}
|