@curl-runner/cli 1.16.0 → 1.16.2

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.
Files changed (40) hide show
  1. package/package.json +2 -2
  2. package/src/ci-exit.test.ts +0 -216
  3. package/src/cli.ts +0 -1351
  4. package/src/commands/upgrade.ts +0 -262
  5. package/src/diff/baseline-manager.test.ts +0 -181
  6. package/src/diff/baseline-manager.ts +0 -266
  7. package/src/diff/diff-formatter.ts +0 -316
  8. package/src/diff/index.ts +0 -3
  9. package/src/diff/response-differ.test.ts +0 -330
  10. package/src/diff/response-differ.ts +0 -489
  11. package/src/executor/max-concurrency.test.ts +0 -139
  12. package/src/executor/profile-executor.test.ts +0 -132
  13. package/src/executor/profile-executor.ts +0 -167
  14. package/src/executor/request-executor.ts +0 -663
  15. package/src/parser/yaml.test.ts +0 -480
  16. package/src/parser/yaml.ts +0 -271
  17. package/src/snapshot/index.ts +0 -3
  18. package/src/snapshot/snapshot-differ.test.ts +0 -358
  19. package/src/snapshot/snapshot-differ.ts +0 -296
  20. package/src/snapshot/snapshot-formatter.ts +0 -170
  21. package/src/snapshot/snapshot-manager.test.ts +0 -204
  22. package/src/snapshot/snapshot-manager.ts +0 -342
  23. package/src/types/bun-yaml.d.ts +0 -11
  24. package/src/types/config.ts +0 -638
  25. package/src/utils/colors.ts +0 -30
  26. package/src/utils/condition-evaluator.test.ts +0 -415
  27. package/src/utils/condition-evaluator.ts +0 -327
  28. package/src/utils/curl-builder.test.ts +0 -165
  29. package/src/utils/curl-builder.ts +0 -209
  30. package/src/utils/installation-detector.test.ts +0 -52
  31. package/src/utils/installation-detector.ts +0 -123
  32. package/src/utils/logger.ts +0 -856
  33. package/src/utils/response-store.test.ts +0 -213
  34. package/src/utils/response-store.ts +0 -108
  35. package/src/utils/stats.test.ts +0 -161
  36. package/src/utils/stats.ts +0 -151
  37. package/src/utils/version-checker.ts +0 -158
  38. package/src/version.ts +0 -43
  39. package/src/watcher/file-watcher.test.ts +0 -186
  40. package/src/watcher/file-watcher.ts +0 -140
@@ -1,663 +0,0 @@
1
- import { YamlParser } from '../parser/yaml';
2
- import { SnapshotManager } from '../snapshot/snapshot-manager';
3
- import type {
4
- ExecutionResult,
5
- ExecutionSummary,
6
- FileAttachment,
7
- FormFieldValue,
8
- GlobalConfig,
9
- JsonValue,
10
- RequestConfig,
11
- ResponseStoreContext,
12
- SnapshotConfig,
13
- } from '../types/config';
14
- import { evaluateCondition } from '../utils/condition-evaluator';
15
- import { CurlBuilder } from '../utils/curl-builder';
16
- import { Logger } from '../utils/logger';
17
- import { createStoreContext, extractStoreValues } from '../utils/response-store';
18
-
19
- export class RequestExecutor {
20
- private logger: Logger;
21
- private globalConfig: GlobalConfig;
22
- private snapshotManager: SnapshotManager;
23
-
24
- constructor(globalConfig: GlobalConfig = {}) {
25
- this.globalConfig = globalConfig;
26
- this.logger = new Logger(globalConfig.output);
27
- this.snapshotManager = new SnapshotManager(globalConfig.snapshot);
28
- }
29
-
30
- private mergeOutputConfig(config: RequestConfig): GlobalConfig['output'] {
31
- // Precedence: Individual YAML file > curl-runner.yaml > CLI options > env vars > defaults
32
- return {
33
- ...this.globalConfig.output, // CLI options, env vars, and defaults (lowest priority)
34
- ...config.sourceOutputConfig, // Individual file's output config (highest priority)
35
- };
36
- }
37
-
38
- /**
39
- * Gets the effective snapshot config for a request.
40
- */
41
- private getSnapshotConfig(config: RequestConfig): SnapshotConfig | null {
42
- return SnapshotManager.mergeConfig(this.globalConfig.snapshot, config.snapshot);
43
- }
44
-
45
- /**
46
- * Checks if a form field value is a file attachment.
47
- */
48
- private isFileAttachment(value: FormFieldValue): value is FileAttachment {
49
- return typeof value === 'object' && value !== null && 'file' in value;
50
- }
51
-
52
- /**
53
- * Validates that all file attachments in formData exist.
54
- * Returns an error message if any file is missing, or undefined if all files exist.
55
- */
56
- private async validateFileAttachments(config: RequestConfig): Promise<string | undefined> {
57
- if (!config.formData) {
58
- return undefined;
59
- }
60
-
61
- const missingFiles: string[] = [];
62
-
63
- for (const [fieldName, fieldValue] of Object.entries(config.formData)) {
64
- if (this.isFileAttachment(fieldValue)) {
65
- const filePath = fieldValue.file;
66
- const file = Bun.file(filePath);
67
- const exists = await file.exists();
68
- if (!exists) {
69
- missingFiles.push(`${fieldName}: ${filePath}`);
70
- }
71
- }
72
- }
73
-
74
- if (missingFiles.length > 0) {
75
- return `File(s) not found: ${missingFiles.join(', ')}`;
76
- }
77
-
78
- return undefined;
79
- }
80
-
81
- async executeRequest(config: RequestConfig, index: number = 0): Promise<ExecutionResult> {
82
- const startTime = performance.now();
83
-
84
- // Create per-request logger with merged output configuration
85
- const outputConfig = this.mergeOutputConfig(config);
86
- const requestLogger = new Logger(outputConfig);
87
-
88
- requestLogger.logRequestStart(config, index);
89
-
90
- // Validate file attachments exist before executing
91
- const fileError = await this.validateFileAttachments(config);
92
- if (fileError) {
93
- const failedResult: ExecutionResult = {
94
- request: config,
95
- success: false,
96
- error: fileError,
97
- metrics: {
98
- duration: performance.now() - startTime,
99
- },
100
- };
101
- requestLogger.logRequestComplete(failedResult);
102
- return failedResult;
103
- }
104
-
105
- const command = CurlBuilder.buildCommand(config);
106
- requestLogger.logCommand(command);
107
-
108
- let attempt = 0;
109
- let lastError: string | undefined;
110
- const maxAttempts = (config.retry?.count || 0) + 1;
111
-
112
- while (attempt < maxAttempts) {
113
- if (attempt > 0) {
114
- requestLogger.logRetry(attempt, maxAttempts - 1);
115
- if (config.retry?.delay) {
116
- const backoff = config.retry.backoff ?? 1;
117
- const delay = config.retry.delay * backoff ** (attempt - 1);
118
- await Bun.sleep(delay);
119
- }
120
- }
121
-
122
- const result = await CurlBuilder.executeCurl(command);
123
-
124
- if (result.success) {
125
- let body = result.body;
126
- try {
127
- if (
128
- result.headers?.['content-type']?.includes('application/json') ||
129
- (body && (body.trim().startsWith('{') || body.trim().startsWith('[')))
130
- ) {
131
- body = JSON.parse(body);
132
- }
133
- } catch (_e) {}
134
-
135
- const executionResult: ExecutionResult = {
136
- request: config,
137
- success: true,
138
- status: result.status,
139
- headers: result.headers,
140
- body,
141
- metrics: {
142
- ...result.metrics,
143
- duration: performance.now() - startTime,
144
- },
145
- };
146
-
147
- if (config.expect) {
148
- const validationResult = this.validateResponse(executionResult, config.expect);
149
- if (!validationResult.success) {
150
- executionResult.success = false;
151
- executionResult.error = validationResult.error;
152
- }
153
- }
154
-
155
- // Snapshot testing
156
- const snapshotConfig = this.getSnapshotConfig(config);
157
- if (snapshotConfig && config.sourceFile) {
158
- const snapshotResult = await this.snapshotManager.compareAndUpdate(
159
- config.sourceFile,
160
- config.name || 'Request',
161
- executionResult,
162
- snapshotConfig,
163
- );
164
- executionResult.snapshotResult = snapshotResult;
165
-
166
- if (!snapshotResult.match && !snapshotResult.updated) {
167
- executionResult.success = false;
168
- if (!executionResult.error) {
169
- executionResult.error = 'Snapshot mismatch';
170
- }
171
- }
172
- }
173
-
174
- requestLogger.logRequestComplete(executionResult);
175
- return executionResult;
176
- }
177
-
178
- lastError = result.error;
179
- attempt++;
180
- }
181
-
182
- const failedResult: ExecutionResult = {
183
- request: config,
184
- success: false,
185
- error: lastError,
186
- metrics: {
187
- duration: performance.now() - startTime,
188
- },
189
- };
190
-
191
- requestLogger.logRequestComplete(failedResult);
192
- return failedResult;
193
- }
194
-
195
- private validateResponse(
196
- result: ExecutionResult,
197
- expect: RequestConfig['expect'],
198
- ): { success: boolean; error?: string } {
199
- if (!expect) {
200
- return { success: true };
201
- }
202
-
203
- const errors: string[] = [];
204
-
205
- // Validate status
206
- if (expect.status !== undefined) {
207
- const expectedStatuses = Array.isArray(expect.status) ? expect.status : [expect.status];
208
- if (!expectedStatuses.includes(result.status || 0)) {
209
- errors.push(`Expected status ${expectedStatuses.join(' or ')}, got ${result.status}`);
210
- }
211
- }
212
-
213
- // Validate headers
214
- if (expect.headers) {
215
- for (const [key, value] of Object.entries(expect.headers)) {
216
- const actualValue = result.headers?.[key] || result.headers?.[key.toLowerCase()];
217
- if (actualValue !== value) {
218
- errors.push(`Expected header ${key}="${value}", got "${actualValue}"`);
219
- }
220
- }
221
- }
222
-
223
- // Validate body
224
- if (expect.body !== undefined) {
225
- const bodyErrors = this.validateBodyProperties(result.body, expect.body, '');
226
- if (bodyErrors.length > 0) {
227
- errors.push(...bodyErrors);
228
- }
229
- }
230
-
231
- // Validate response time
232
- if (expect.responseTime !== undefined && result.metrics) {
233
- const responseTimeMs = result.metrics.duration;
234
- if (!this.validateRangePattern(responseTimeMs, expect.responseTime)) {
235
- errors.push(
236
- `Expected response time to match ${expect.responseTime}ms, got ${responseTimeMs.toFixed(2)}ms`,
237
- );
238
- }
239
- }
240
-
241
- const hasValidationErrors = errors.length > 0;
242
-
243
- // Handle failure expectation logic
244
- if (expect.failure === true) {
245
- // We expect this request to fail (negative testing)
246
- // Success means: validations pass AND status indicates error (4xx/5xx)
247
- if (hasValidationErrors) {
248
- return { success: false, error: errors.join('; ') };
249
- }
250
-
251
- // Check if status indicates an error
252
- const status = result.status || 0;
253
- if (status >= 400) {
254
- // Status indicates error and validations passed - SUCCESS for negative testing
255
- return { success: true };
256
- } else {
257
- // Expected failure but got success status - FAILURE
258
- return {
259
- success: false,
260
- error: `Expected request to fail (4xx/5xx) but got status ${status}`,
261
- };
262
- }
263
- } else {
264
- // Normal case: expect success (validations should pass)
265
- if (hasValidationErrors) {
266
- return { success: false, error: errors.join('; ') };
267
- } else {
268
- return { success: true };
269
- }
270
- }
271
- }
272
-
273
- private validateBodyProperties(
274
- actualBody: JsonValue,
275
- expectedBody: JsonValue,
276
- path: string,
277
- ): string[] {
278
- const errors: string[] = [];
279
-
280
- if (typeof expectedBody !== 'object' || expectedBody === null) {
281
- // Advanced value validation
282
- const validationResult = this.validateValue(actualBody, expectedBody, path || 'body');
283
- if (!validationResult.isValid) {
284
- errors.push(validationResult.error!);
285
- }
286
- return errors;
287
- }
288
-
289
- // Array validation
290
- if (Array.isArray(expectedBody)) {
291
- const validationResult = this.validateValue(actualBody, expectedBody, path || 'body');
292
- if (!validationResult.isValid) {
293
- errors.push(validationResult.error!);
294
- }
295
- return errors;
296
- }
297
-
298
- // Object property comparison with array selector support
299
- for (const [key, expectedValue] of Object.entries(expectedBody)) {
300
- const currentPath = path ? `${path}.${key}` : key;
301
- let actualValue: JsonValue;
302
-
303
- // Handle array selectors like [0], [-1], *, slice(0,3)
304
- if (Array.isArray(actualBody) && this.isArraySelector(key)) {
305
- actualValue = this.getArrayValue(actualBody, key);
306
- } else {
307
- actualValue = actualBody?.[key];
308
- }
309
-
310
- if (
311
- typeof expectedValue === 'object' &&
312
- expectedValue !== null &&
313
- !Array.isArray(expectedValue)
314
- ) {
315
- // Recursive validation for nested objects
316
- const nestedErrors = this.validateBodyProperties(actualValue, expectedValue, currentPath);
317
- errors.push(...nestedErrors);
318
- } else {
319
- // Advanced value validation
320
- const validationResult = this.validateValue(actualValue, expectedValue, currentPath);
321
- if (!validationResult.isValid) {
322
- errors.push(validationResult.error!);
323
- }
324
- }
325
- }
326
-
327
- return errors;
328
- }
329
-
330
- private validateValue(
331
- actualValue: JsonValue,
332
- expectedValue: JsonValue,
333
- path: string,
334
- ): { isValid: boolean; error?: string } {
335
- // Wildcard validation - accept any value
336
- if (expectedValue === '*') {
337
- return { isValid: true };
338
- }
339
-
340
- // Multiple acceptable values (array)
341
- if (Array.isArray(expectedValue)) {
342
- const isMatch = expectedValue.some((expected) => {
343
- if (expected === '*') {
344
- return true;
345
- }
346
- if (typeof expected === 'string' && this.isRegexPattern(expected)) {
347
- return this.validateRegexPattern(actualValue, expected);
348
- }
349
- if (typeof expected === 'string' && this.isRangePattern(expected)) {
350
- return this.validateRangePattern(actualValue, expected);
351
- }
352
- return actualValue === expected;
353
- });
354
-
355
- if (!isMatch) {
356
- return {
357
- isValid: false,
358
- error: `Expected ${path} to match one of ${JSON.stringify(expectedValue)}, got ${JSON.stringify(actualValue)}`,
359
- };
360
- }
361
- return { isValid: true };
362
- }
363
-
364
- // Regex pattern validation
365
- if (typeof expectedValue === 'string' && this.isRegexPattern(expectedValue)) {
366
- if (!this.validateRegexPattern(actualValue, expectedValue)) {
367
- return {
368
- isValid: false,
369
- error: `Expected ${path} to match pattern ${expectedValue}, got ${JSON.stringify(actualValue)}`,
370
- };
371
- }
372
- return { isValid: true };
373
- }
374
-
375
- // Numeric range validation
376
- if (typeof expectedValue === 'string' && this.isRangePattern(expectedValue)) {
377
- if (!this.validateRangePattern(actualValue, expectedValue)) {
378
- return {
379
- isValid: false,
380
- error: `Expected ${path} to match range ${expectedValue}, got ${JSON.stringify(actualValue)}`,
381
- };
382
- }
383
- return { isValid: true };
384
- }
385
-
386
- // Null handling
387
- if (expectedValue === 'null' || expectedValue === null) {
388
- if (actualValue !== null) {
389
- return {
390
- isValid: false,
391
- error: `Expected ${path} to be null, got ${JSON.stringify(actualValue)}`,
392
- };
393
- }
394
- return { isValid: true };
395
- }
396
-
397
- // Exact value comparison
398
- if (actualValue !== expectedValue) {
399
- return {
400
- isValid: false,
401
- error: `Expected ${path} to be ${JSON.stringify(expectedValue)}, got ${JSON.stringify(actualValue)}`,
402
- };
403
- }
404
-
405
- return { isValid: true };
406
- }
407
-
408
- private isRegexPattern(pattern: string): boolean {
409
- return (
410
- pattern.startsWith('^') ||
411
- pattern.endsWith('$') ||
412
- pattern.includes('\\d') ||
413
- pattern.includes('\\w') ||
414
- pattern.includes('\\s') ||
415
- pattern.includes('[') ||
416
- pattern.includes('*') ||
417
- pattern.includes('+') ||
418
- pattern.includes('?')
419
- );
420
- }
421
-
422
- private validateRegexPattern(actualValue: JsonValue, pattern: string): boolean {
423
- // Convert value to string for regex matching
424
- const stringValue = String(actualValue);
425
- try {
426
- const regex = new RegExp(pattern);
427
- return regex.test(stringValue);
428
- } catch {
429
- return false;
430
- }
431
- }
432
-
433
- private isRangePattern(pattern: string): boolean {
434
- // Only match explicit comparison operators, not simple number-dash-number patterns
435
- // This prevents matching things like zip codes "92998-3874" as ranges
436
- return /^(>=?|<=?|>|<)\s*[\d.-]+(\s*,\s*(>=?|<=?|>|<)\s*[\d.-]+)*$/.test(pattern);
437
- }
438
-
439
- private validateRangePattern(actualValue: JsonValue, pattern: string): boolean {
440
- const numValue = Number(actualValue);
441
- if (Number.isNaN(numValue)) {
442
- return false;
443
- }
444
-
445
- // Handle range like "10 - 50" or "10-50"
446
- const rangeMatch = pattern.match(/^([\d.-]+)\s*-\s*([\d.-]+)$/);
447
- if (rangeMatch) {
448
- const min = Number(rangeMatch[1]);
449
- const max = Number(rangeMatch[2]);
450
- return numValue >= min && numValue <= max;
451
- }
452
-
453
- // Handle comma-separated conditions like ">= 0, <= 100"
454
- const conditions = pattern.split(',').map((c) => c.trim());
455
- return conditions.every((condition) => {
456
- const match = condition.match(/^(>=?|<=?|>|<)\s*([\d.-]+)$/);
457
- if (!match) {
458
- return false;
459
- }
460
-
461
- const operator = match[1];
462
- const targetValue = Number(match[2]);
463
-
464
- switch (operator) {
465
- case '>':
466
- return numValue > targetValue;
467
- case '>=':
468
- return numValue >= targetValue;
469
- case '<':
470
- return numValue < targetValue;
471
- case '<=':
472
- return numValue <= targetValue;
473
- default:
474
- return false;
475
- }
476
- });
477
- }
478
-
479
- private isArraySelector(key: string): boolean {
480
- return /^\[.*\]$/.test(key) || key === '*' || key.startsWith('slice(');
481
- }
482
-
483
- private getArrayValue(array: JsonValue[], selector: string): JsonValue {
484
- if (selector === '*') {
485
- return array; // Return entire array for * validation
486
- }
487
-
488
- if (selector.startsWith('[') && selector.endsWith(']')) {
489
- const index = selector.slice(1, -1);
490
- if (index === '*') {
491
- return array;
492
- }
493
-
494
- const numIndex = Number(index);
495
- if (!Number.isNaN(numIndex)) {
496
- return numIndex >= 0 ? array[numIndex] : array[array.length + numIndex];
497
- }
498
- }
499
-
500
- if (selector.startsWith('slice(')) {
501
- const match = selector.match(/slice\((\d+)(?:,(\d+))?\)/);
502
- if (match) {
503
- const start = Number(match[1]);
504
- const end = match[2] ? Number(match[2]) : undefined;
505
- return array.slice(start, end);
506
- }
507
- }
508
-
509
- return undefined;
510
- }
511
-
512
- async executeSequential(requests: RequestConfig[]): Promise<ExecutionSummary> {
513
- const startTime = performance.now();
514
- const results: ExecutionResult[] = [];
515
- const storeContext = createStoreContext();
516
-
517
- for (let i = 0; i < requests.length; i++) {
518
- // Interpolate store variables before execution
519
- const interpolatedRequest = this.interpolateStoreVariables(requests[i], storeContext);
520
-
521
- // Evaluate `when` condition if present
522
- if (interpolatedRequest.when) {
523
- const conditionResult = evaluateCondition(interpolatedRequest.when, storeContext);
524
- if (!conditionResult.shouldRun) {
525
- const skippedResult: ExecutionResult = {
526
- request: interpolatedRequest,
527
- success: true, // Skipped requests are not failures
528
- skipped: true,
529
- skipReason: conditionResult.reason,
530
- };
531
- results.push(skippedResult);
532
- this.logger.logSkipped(interpolatedRequest, i + 1, conditionResult.reason);
533
- continue;
534
- }
535
- }
536
-
537
- const result = await this.executeRequest(interpolatedRequest, i + 1);
538
- results.push(result);
539
-
540
- // Store values from successful responses
541
- if (result.success && interpolatedRequest.store) {
542
- const storedValues = extractStoreValues(result, interpolatedRequest.store);
543
- Object.assign(storeContext, storedValues);
544
- this.logStoredValues(storedValues);
545
- }
546
-
547
- if (!result.success && !this.globalConfig.continueOnError) {
548
- this.logger.logError('Stopping execution due to error');
549
- break;
550
- }
551
- }
552
-
553
- return this.createSummary(results, performance.now() - startTime);
554
- }
555
-
556
- /**
557
- * Interpolates store variables (${store.variableName}) in a request config.
558
- * This is called at execution time to resolve values from previous responses.
559
- */
560
- private interpolateStoreVariables(
561
- request: RequestConfig,
562
- storeContext: ResponseStoreContext,
563
- ): RequestConfig {
564
- // Only interpolate if there are stored values
565
- if (Object.keys(storeContext).length === 0) {
566
- return request;
567
- }
568
-
569
- // Re-interpolate the request with store context
570
- // We pass empty variables since static variables were already resolved
571
- return YamlParser.interpolateVariables(request, {}, storeContext) as RequestConfig;
572
- }
573
-
574
- /**
575
- * Logs stored values for debugging purposes.
576
- */
577
- private logStoredValues(values: ResponseStoreContext): void {
578
- if (Object.keys(values).length === 0) {
579
- return;
580
- }
581
-
582
- const entries = Object.entries(values);
583
- for (const [key, value] of entries) {
584
- const displayValue = value.length > 50 ? `${value.substring(0, 50)}...` : value;
585
- this.logger.logInfo(`Stored: ${key} = "${displayValue}"`);
586
- }
587
- }
588
-
589
- async executeParallel(requests: RequestConfig[]): Promise<ExecutionSummary> {
590
- const startTime = performance.now();
591
- const maxConcurrency = this.globalConfig.maxConcurrency;
592
-
593
- // If no concurrency limit, execute all requests simultaneously
594
- if (!maxConcurrency || maxConcurrency >= requests.length) {
595
- const promises = requests.map((request, index) => this.executeRequest(request, index + 1));
596
- const results = await Promise.all(promises);
597
- return this.createSummary(results, performance.now() - startTime);
598
- }
599
-
600
- // Execute in chunks with limited concurrency
601
- const results: ExecutionResult[] = [];
602
- for (let i = 0; i < requests.length; i += maxConcurrency) {
603
- const chunk = requests.slice(i, i + maxConcurrency);
604
- const chunkPromises = chunk.map((request, chunkIndex) =>
605
- this.executeRequest(request, i + chunkIndex + 1),
606
- );
607
- const chunkResults = await Promise.all(chunkPromises);
608
- results.push(...chunkResults);
609
-
610
- // Check if we should stop on error
611
- const hasError = chunkResults.some((r) => !r.success);
612
- if (hasError && !this.globalConfig.continueOnError) {
613
- this.logger.logError('Stopping execution due to error');
614
- break;
615
- }
616
- }
617
-
618
- return this.createSummary(results, performance.now() - startTime);
619
- }
620
-
621
- async execute(requests: RequestConfig[]): Promise<ExecutionSummary> {
622
- this.logger.logExecutionStart(requests.length, this.globalConfig.execution || 'sequential');
623
-
624
- const summary =
625
- this.globalConfig.execution === 'parallel'
626
- ? await this.executeParallel(requests)
627
- : await this.executeSequential(requests);
628
-
629
- this.logger.logSummary(summary);
630
-
631
- if (this.globalConfig.output?.saveToFile) {
632
- await this.saveSummaryToFile(summary);
633
- }
634
-
635
- return summary;
636
- }
637
-
638
- private createSummary(results: ExecutionResult[], duration: number): ExecutionSummary {
639
- const skipped = results.filter((r) => r.skipped).length;
640
- const successful = results.filter((r) => r.success && !r.skipped).length;
641
- const failed = results.filter((r) => !r.success).length;
642
-
643
- return {
644
- total: results.length,
645
- successful,
646
- failed,
647
- skipped,
648
- duration,
649
- results,
650
- };
651
- }
652
-
653
- private async saveSummaryToFile(summary: ExecutionSummary): Promise<void> {
654
- const file = this.globalConfig.output?.saveToFile;
655
- if (!file) {
656
- return;
657
- }
658
-
659
- const content = JSON.stringify(summary, null, 2);
660
- await Bun.write(file, content);
661
- this.logger.logInfo(`Results saved to ${file}`);
662
- }
663
- }