@curl-runner/cli 1.9.0 → 1.11.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 +234 -41
- package/src/executor/request-executor.ts +31 -1
- package/src/snapshot/index.ts +3 -0
- package/src/snapshot/snapshot-differ.test.ts +358 -0
- package/src/snapshot/snapshot-differ.ts +296 -0
- package/src/snapshot/snapshot-formatter.ts +170 -0
- package/src/snapshot/snapshot-manager.test.ts +204 -0
- package/src/snapshot/snapshot-manager.ts +342 -0
- package/src/types/config.ts +98 -0
- package/src/utils/logger.ts +49 -0
- package/src/watcher/file-watcher.test.ts +186 -0
- package/src/watcher/file-watcher.ts +140 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -3,10 +3,17 @@
|
|
|
3
3
|
import { Glob } from 'bun';
|
|
4
4
|
import { RequestExecutor } from './executor/request-executor';
|
|
5
5
|
import { YamlParser } from './parser/yaml';
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
ExecutionResult,
|
|
8
|
+
ExecutionSummary,
|
|
9
|
+
GlobalConfig,
|
|
10
|
+
RequestConfig,
|
|
11
|
+
WatchConfig,
|
|
12
|
+
} from './types/config';
|
|
7
13
|
import { Logger } from './utils/logger';
|
|
8
14
|
import { VersionChecker } from './utils/version-checker';
|
|
9
15
|
import { getVersion } from './version';
|
|
16
|
+
import { FileWatcher } from './watcher/file-watcher';
|
|
10
17
|
|
|
11
18
|
class CurlRunnerCLI {
|
|
12
19
|
private logger = new Logger();
|
|
@@ -137,6 +144,60 @@ class CurlRunnerCLI {
|
|
|
137
144
|
}
|
|
138
145
|
}
|
|
139
146
|
|
|
147
|
+
// Watch mode configuration
|
|
148
|
+
if (process.env.CURL_RUNNER_WATCH) {
|
|
149
|
+
envConfig.watch = {
|
|
150
|
+
...envConfig.watch,
|
|
151
|
+
enabled: process.env.CURL_RUNNER_WATCH.toLowerCase() === 'true',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (process.env.CURL_RUNNER_WATCH_DEBOUNCE) {
|
|
156
|
+
envConfig.watch = {
|
|
157
|
+
...envConfig.watch,
|
|
158
|
+
debounce: Number.parseInt(process.env.CURL_RUNNER_WATCH_DEBOUNCE, 10),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (process.env.CURL_RUNNER_WATCH_CLEAR) {
|
|
163
|
+
envConfig.watch = {
|
|
164
|
+
...envConfig.watch,
|
|
165
|
+
clear: process.env.CURL_RUNNER_WATCH_CLEAR.toLowerCase() !== 'false',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Snapshot configuration
|
|
170
|
+
if (process.env.CURL_RUNNER_SNAPSHOT) {
|
|
171
|
+
envConfig.snapshot = {
|
|
172
|
+
...envConfig.snapshot,
|
|
173
|
+
enabled: process.env.CURL_RUNNER_SNAPSHOT.toLowerCase() === 'true',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (process.env.CURL_RUNNER_SNAPSHOT_UPDATE) {
|
|
178
|
+
const mode = process.env.CURL_RUNNER_SNAPSHOT_UPDATE.toLowerCase();
|
|
179
|
+
if (['none', 'all', 'failing'].includes(mode)) {
|
|
180
|
+
envConfig.snapshot = {
|
|
181
|
+
...envConfig.snapshot,
|
|
182
|
+
updateMode: mode as 'none' | 'all' | 'failing',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (process.env.CURL_RUNNER_SNAPSHOT_DIR) {
|
|
188
|
+
envConfig.snapshot = {
|
|
189
|
+
...envConfig.snapshot,
|
|
190
|
+
dir: process.env.CURL_RUNNER_SNAPSHOT_DIR,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (process.env.CURL_RUNNER_SNAPSHOT_CI) {
|
|
195
|
+
envConfig.snapshot = {
|
|
196
|
+
...envConfig.snapshot,
|
|
197
|
+
ci: process.env.CURL_RUNNER_SNAPSHOT_CI.toLowerCase() === 'true',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
140
201
|
return envConfig;
|
|
141
202
|
}
|
|
142
203
|
|
|
@@ -282,63 +343,139 @@ class CurlRunnerCLI {
|
|
|
282
343
|
};
|
|
283
344
|
}
|
|
284
345
|
|
|
346
|
+
// Apply snapshot options
|
|
347
|
+
if (options.snapshot !== undefined) {
|
|
348
|
+
globalConfig.snapshot = {
|
|
349
|
+
...globalConfig.snapshot,
|
|
350
|
+
enabled: options.snapshot as boolean,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (options.snapshotUpdate !== undefined) {
|
|
354
|
+
globalConfig.snapshot = {
|
|
355
|
+
...globalConfig.snapshot,
|
|
356
|
+
enabled: true,
|
|
357
|
+
updateMode: options.snapshotUpdate as 'none' | 'all' | 'failing',
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (options.snapshotDir !== undefined) {
|
|
361
|
+
globalConfig.snapshot = {
|
|
362
|
+
...globalConfig.snapshot,
|
|
363
|
+
dir: options.snapshotDir as string,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (options.snapshotCi !== undefined) {
|
|
367
|
+
globalConfig.snapshot = {
|
|
368
|
+
...globalConfig.snapshot,
|
|
369
|
+
ci: options.snapshotCi as boolean,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
285
373
|
if (allRequests.length === 0) {
|
|
286
374
|
this.logger.logError('No requests found in YAML files');
|
|
287
375
|
process.exit(1);
|
|
288
376
|
}
|
|
289
377
|
|
|
290
|
-
|
|
291
|
-
|
|
378
|
+
// Check if watch mode is enabled
|
|
379
|
+
const watchEnabled = options.watch || globalConfig.watch?.enabled;
|
|
380
|
+
|
|
381
|
+
if (watchEnabled) {
|
|
382
|
+
// Build watch config from options and global config
|
|
383
|
+
const watchConfig: WatchConfig = {
|
|
384
|
+
enabled: true,
|
|
385
|
+
debounce:
|
|
386
|
+
(options.watchDebounce as number | undefined) ?? globalConfig.watch?.debounce ?? 300,
|
|
387
|
+
clear: (options.watchClear as boolean | undefined) ?? globalConfig.watch?.clear ?? true,
|
|
388
|
+
};
|
|
292
389
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
390
|
+
const watcher = new FileWatcher({
|
|
391
|
+
files: yamlFiles,
|
|
392
|
+
config: watchConfig,
|
|
393
|
+
logger: this.logger,
|
|
394
|
+
onRun: async () => {
|
|
395
|
+
await this.executeRequests(yamlFiles, globalConfig);
|
|
396
|
+
},
|
|
397
|
+
});
|
|
297
398
|
|
|
298
|
-
|
|
299
|
-
|
|
399
|
+
await watcher.start();
|
|
400
|
+
} else {
|
|
401
|
+
// Normal execution mode
|
|
402
|
+
const summary = await this.executeRequests(yamlFiles, globalConfig);
|
|
403
|
+
const exitCode = this.determineExitCode(summary, globalConfig);
|
|
404
|
+
process.exit(exitCode);
|
|
405
|
+
}
|
|
406
|
+
} catch (error) {
|
|
407
|
+
this.logger.logError(error instanceof Error ? error.message : String(error));
|
|
408
|
+
process.exit(1);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
300
411
|
|
|
301
|
-
|
|
302
|
-
|
|
412
|
+
private async executeRequests(
|
|
413
|
+
yamlFiles: string[],
|
|
414
|
+
globalConfig: GlobalConfig,
|
|
415
|
+
): Promise<ExecutionSummary> {
|
|
416
|
+
// Process YAML files and collect requests
|
|
417
|
+
const fileGroups: Array<{ file: string; requests: RequestConfig[]; config?: GlobalConfig }> =
|
|
418
|
+
[];
|
|
419
|
+
const allRequests: RequestConfig[] = [];
|
|
420
|
+
|
|
421
|
+
for (const file of yamlFiles) {
|
|
422
|
+
const { requests, config } = await this.processYamlFile(file);
|
|
423
|
+
|
|
424
|
+
const fileOutputConfig = config?.output || {};
|
|
425
|
+
const requestsWithSourceConfig = requests.map((request) => ({
|
|
426
|
+
...request,
|
|
427
|
+
sourceOutputConfig: fileOutputConfig,
|
|
428
|
+
sourceFile: file,
|
|
429
|
+
}));
|
|
430
|
+
|
|
431
|
+
fileGroups.push({ file, requests: requestsWithSourceConfig, config });
|
|
432
|
+
allRequests.push(...requestsWithSourceConfig);
|
|
433
|
+
}
|
|
303
434
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
totalDuration += fileSummary.duration;
|
|
435
|
+
const executor = new RequestExecutor(globalConfig);
|
|
436
|
+
let summary: ExecutionSummary;
|
|
307
437
|
|
|
308
|
-
|
|
438
|
+
// If multiple files, execute them with file separators for clarity
|
|
439
|
+
if (fileGroups.length > 1) {
|
|
440
|
+
const allResults: ExecutionResult[] = [];
|
|
441
|
+
let totalDuration = 0;
|
|
309
442
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
console.log();
|
|
313
|
-
}
|
|
314
|
-
}
|
|
443
|
+
for (let i = 0; i < fileGroups.length; i++) {
|
|
444
|
+
const group = fileGroups[i];
|
|
315
445
|
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
const failed = allResults.filter((r) => !r.success).length;
|
|
446
|
+
// Show file header for better organization
|
|
447
|
+
this.logger.logFileHeader(group.file, group.requests.length);
|
|
319
448
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
failed,
|
|
324
|
-
duration: totalDuration,
|
|
325
|
-
results: allResults,
|
|
326
|
-
};
|
|
449
|
+
const fileSummary = await executor.execute(group.requests);
|
|
450
|
+
allResults.push(...fileSummary.results);
|
|
451
|
+
totalDuration += fileSummary.duration;
|
|
327
452
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
summary = await executor.execute(allRequests);
|
|
453
|
+
// Add spacing between files (except for the last one)
|
|
454
|
+
if (i < fileGroups.length - 1) {
|
|
455
|
+
console.log();
|
|
456
|
+
}
|
|
333
457
|
}
|
|
334
458
|
|
|
335
|
-
//
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
459
|
+
// Create combined summary
|
|
460
|
+
const successful = allResults.filter((r) => r.success).length;
|
|
461
|
+
const failed = allResults.filter((r) => !r.success).length;
|
|
462
|
+
|
|
463
|
+
summary = {
|
|
464
|
+
total: allResults.length,
|
|
465
|
+
successful,
|
|
466
|
+
failed,
|
|
467
|
+
duration: totalDuration,
|
|
468
|
+
results: allResults,
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// Show final summary
|
|
472
|
+
this.logger.logSummary(summary, true);
|
|
473
|
+
} else {
|
|
474
|
+
// Single file - use normal execution
|
|
475
|
+
summary = await executor.execute(allRequests);
|
|
341
476
|
}
|
|
477
|
+
|
|
478
|
+
return summary;
|
|
342
479
|
}
|
|
343
480
|
|
|
344
481
|
private parseArguments(args: string[]): { files: string[]; options: Record<string, unknown> } {
|
|
@@ -366,6 +503,20 @@ class CurlRunnerCLI {
|
|
|
366
503
|
options.showMetrics = true;
|
|
367
504
|
} else if (key === 'strict-exit') {
|
|
368
505
|
options.strictExit = true;
|
|
506
|
+
} else if (key === 'watch') {
|
|
507
|
+
options.watch = true;
|
|
508
|
+
} else if (key === 'watch-clear') {
|
|
509
|
+
options.watchClear = true;
|
|
510
|
+
} else if (key === 'no-watch-clear') {
|
|
511
|
+
options.watchClear = false;
|
|
512
|
+
} else if (key === 'snapshot') {
|
|
513
|
+
options.snapshot = true;
|
|
514
|
+
} else if (key === 'update-snapshots') {
|
|
515
|
+
options.snapshotUpdate = 'all';
|
|
516
|
+
} else if (key === 'update-failing') {
|
|
517
|
+
options.snapshotUpdate = 'failing';
|
|
518
|
+
} else if (key === 'ci-snapshot') {
|
|
519
|
+
options.snapshotCi = true;
|
|
369
520
|
} else if (nextArg && !nextArg.startsWith('--')) {
|
|
370
521
|
if (key === 'continue-on-error') {
|
|
371
522
|
options.continueOnError = nextArg === 'true';
|
|
@@ -397,6 +548,10 @@ class CurlRunnerCLI {
|
|
|
397
548
|
if (['minimal', 'standard', 'detailed'].includes(nextArg)) {
|
|
398
549
|
options.prettyLevel = nextArg;
|
|
399
550
|
}
|
|
551
|
+
} else if (key === 'watch-debounce') {
|
|
552
|
+
options.watchDebounce = Number.parseInt(nextArg, 10);
|
|
553
|
+
} else if (key === 'snapshot-dir') {
|
|
554
|
+
options.snapshotDir = nextArg;
|
|
400
555
|
} else {
|
|
401
556
|
options[key] = nextArg;
|
|
402
557
|
}
|
|
@@ -423,6 +578,15 @@ class CurlRunnerCLI {
|
|
|
423
578
|
case 'q':
|
|
424
579
|
options.quiet = true;
|
|
425
580
|
break;
|
|
581
|
+
case 'w':
|
|
582
|
+
options.watch = true;
|
|
583
|
+
break;
|
|
584
|
+
case 's':
|
|
585
|
+
options.snapshot = true;
|
|
586
|
+
break;
|
|
587
|
+
case 'u':
|
|
588
|
+
options.snapshotUpdate = 'all';
|
|
589
|
+
break;
|
|
426
590
|
case 'o': {
|
|
427
591
|
// Handle -o flag for output file
|
|
428
592
|
const outputArg = args[i + 1];
|
|
@@ -551,6 +715,8 @@ class CurlRunnerCLI {
|
|
|
551
715
|
output: { ...base.output, ...override.output },
|
|
552
716
|
defaults: { ...base.defaults, ...override.defaults },
|
|
553
717
|
ci: { ...base.ci, ...override.ci },
|
|
718
|
+
watch: { ...base.watch, ...override.watch },
|
|
719
|
+
snapshot: { ...base.snapshot, ...override.snapshot },
|
|
554
720
|
};
|
|
555
721
|
}
|
|
556
722
|
|
|
@@ -633,11 +799,23 @@ ${this.logger.color('OPTIONS:', 'yellow')}
|
|
|
633
799
|
--show-metrics Include performance metrics in output
|
|
634
800
|
--version Show version
|
|
635
801
|
|
|
802
|
+
${this.logger.color('WATCH MODE:', 'yellow')}
|
|
803
|
+
-w, --watch Watch files and re-run on changes
|
|
804
|
+
--watch-debounce <ms> Debounce delay for watch mode (default: 300)
|
|
805
|
+
--no-watch-clear Don't clear screen between watch runs
|
|
806
|
+
|
|
636
807
|
${this.logger.color('CI/CD OPTIONS:', 'yellow')}
|
|
637
808
|
--strict-exit Exit with code 1 if any validation fails (for CI/CD)
|
|
638
809
|
--fail-on <count> Exit with code 1 if failures exceed this count
|
|
639
810
|
--fail-on-percentage <pct> Exit with code 1 if failure percentage exceeds this value
|
|
640
811
|
|
|
812
|
+
${this.logger.color('SNAPSHOT OPTIONS:', 'yellow')}
|
|
813
|
+
-s, --snapshot Enable snapshot testing
|
|
814
|
+
-u, --update-snapshots Update all snapshots
|
|
815
|
+
--update-failing Update only failing snapshots
|
|
816
|
+
--snapshot-dir <dir> Custom snapshot directory (default: __snapshots__)
|
|
817
|
+
--ci-snapshot Fail if snapshot is missing (CI mode)
|
|
818
|
+
|
|
641
819
|
${this.logger.color('EXAMPLES:', 'yellow')}
|
|
642
820
|
# Run all YAML files in current directory
|
|
643
821
|
curl-runner
|
|
@@ -681,6 +859,21 @@ ${this.logger.color('EXAMPLES:', 'yellow')}
|
|
|
681
859
|
# CI/CD: Allow up to 10% failures
|
|
682
860
|
curl-runner tests/ --fail-on-percentage 10
|
|
683
861
|
|
|
862
|
+
# Watch mode - re-run on file changes
|
|
863
|
+
curl-runner api.yaml --watch
|
|
864
|
+
|
|
865
|
+
# Watch with custom debounce
|
|
866
|
+
curl-runner tests/ -w --watch-debounce 500
|
|
867
|
+
|
|
868
|
+
# Snapshot testing - save and compare responses
|
|
869
|
+
curl-runner api.yaml --snapshot
|
|
870
|
+
|
|
871
|
+
# Update all snapshots
|
|
872
|
+
curl-runner api.yaml -su
|
|
873
|
+
|
|
874
|
+
# CI mode - fail if snapshot missing
|
|
875
|
+
curl-runner api.yaml --snapshot --ci-snapshot
|
|
876
|
+
|
|
684
877
|
${this.logger.color('YAML STRUCTURE:', 'yellow')}
|
|
685
878
|
Single request:
|
|
686
879
|
request:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { YamlParser } from '../parser/yaml';
|
|
2
|
+
import { SnapshotManager } from '../snapshot/snapshot-manager';
|
|
2
3
|
import type {
|
|
3
4
|
ExecutionResult,
|
|
4
5
|
ExecutionSummary,
|
|
@@ -8,6 +9,7 @@ import type {
|
|
|
8
9
|
JsonValue,
|
|
9
10
|
RequestConfig,
|
|
10
11
|
ResponseStoreContext,
|
|
12
|
+
SnapshotConfig,
|
|
11
13
|
} from '../types/config';
|
|
12
14
|
import { CurlBuilder } from '../utils/curl-builder';
|
|
13
15
|
import { Logger } from '../utils/logger';
|
|
@@ -16,10 +18,12 @@ import { createStoreContext, extractStoreValues } from '../utils/response-store'
|
|
|
16
18
|
export class RequestExecutor {
|
|
17
19
|
private logger: Logger;
|
|
18
20
|
private globalConfig: GlobalConfig;
|
|
21
|
+
private snapshotManager: SnapshotManager;
|
|
19
22
|
|
|
20
23
|
constructor(globalConfig: GlobalConfig = {}) {
|
|
21
24
|
this.globalConfig = globalConfig;
|
|
22
25
|
this.logger = new Logger(globalConfig.output);
|
|
26
|
+
this.snapshotManager = new SnapshotManager(globalConfig.snapshot);
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
private mergeOutputConfig(config: RequestConfig): GlobalConfig['output'] {
|
|
@@ -30,6 +34,13 @@ export class RequestExecutor {
|
|
|
30
34
|
};
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Gets the effective snapshot config for a request.
|
|
39
|
+
*/
|
|
40
|
+
private getSnapshotConfig(config: RequestConfig): SnapshotConfig | null {
|
|
41
|
+
return SnapshotManager.mergeConfig(this.globalConfig.snapshot, config.snapshot);
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
/**
|
|
34
45
|
* Checks if a form field value is a file attachment.
|
|
35
46
|
*/
|
|
@@ -102,7 +113,7 @@ export class RequestExecutor {
|
|
|
102
113
|
requestLogger.logRetry(attempt, maxAttempts - 1);
|
|
103
114
|
if (config.retry?.delay) {
|
|
104
115
|
const backoff = config.retry.backoff ?? 1;
|
|
105
|
-
const delay = config.retry.delay *
|
|
116
|
+
const delay = config.retry.delay * backoff ** (attempt - 1);
|
|
106
117
|
await Bun.sleep(delay);
|
|
107
118
|
}
|
|
108
119
|
}
|
|
@@ -140,6 +151,25 @@ export class RequestExecutor {
|
|
|
140
151
|
}
|
|
141
152
|
}
|
|
142
153
|
|
|
154
|
+
// Snapshot testing
|
|
155
|
+
const snapshotConfig = this.getSnapshotConfig(config);
|
|
156
|
+
if (snapshotConfig && config.sourceFile) {
|
|
157
|
+
const snapshotResult = await this.snapshotManager.compareAndUpdate(
|
|
158
|
+
config.sourceFile,
|
|
159
|
+
config.name || 'Request',
|
|
160
|
+
executionResult,
|
|
161
|
+
snapshotConfig,
|
|
162
|
+
);
|
|
163
|
+
executionResult.snapshotResult = snapshotResult;
|
|
164
|
+
|
|
165
|
+
if (!snapshotResult.match && !snapshotResult.updated) {
|
|
166
|
+
executionResult.success = false;
|
|
167
|
+
if (!executionResult.error) {
|
|
168
|
+
executionResult.error = 'Snapshot mismatch';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
143
173
|
requestLogger.logRequestComplete(executionResult);
|
|
144
174
|
return executionResult;
|
|
145
175
|
}
|