@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.
@@ -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
+ }