@curl-runner/cli 1.9.0 → 1.10.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 +142 -41
- package/src/executor/request-executor.ts +1 -1
- package/src/types/config.ts +18 -0
- package/src/utils/logger.ts +24 -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,28 @@ 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
|
+
|
|
140
169
|
return envConfig;
|
|
141
170
|
}
|
|
142
171
|
|
|
@@ -287,58 +316,107 @@ class CurlRunnerCLI {
|
|
|
287
316
|
process.exit(1);
|
|
288
317
|
}
|
|
289
318
|
|
|
290
|
-
|
|
291
|
-
|
|
319
|
+
// Check if watch mode is enabled
|
|
320
|
+
const watchEnabled = options.watch || globalConfig.watch?.enabled;
|
|
292
321
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
322
|
+
if (watchEnabled) {
|
|
323
|
+
// Build watch config from options and global config
|
|
324
|
+
const watchConfig: WatchConfig = {
|
|
325
|
+
enabled: true,
|
|
326
|
+
debounce:
|
|
327
|
+
(options.watchDebounce as number | undefined) ?? globalConfig.watch?.debounce ?? 300,
|
|
328
|
+
clear: (options.watchClear as boolean | undefined) ?? globalConfig.watch?.clear ?? true,
|
|
329
|
+
};
|
|
297
330
|
|
|
298
|
-
|
|
299
|
-
|
|
331
|
+
const watcher = new FileWatcher({
|
|
332
|
+
files: yamlFiles,
|
|
333
|
+
config: watchConfig,
|
|
334
|
+
logger: this.logger,
|
|
335
|
+
onRun: async () => {
|
|
336
|
+
await this.executeRequests(yamlFiles, globalConfig);
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await watcher.start();
|
|
341
|
+
} else {
|
|
342
|
+
// Normal execution mode
|
|
343
|
+
const summary = await this.executeRequests(yamlFiles, globalConfig);
|
|
344
|
+
const exitCode = this.determineExitCode(summary, globalConfig);
|
|
345
|
+
process.exit(exitCode);
|
|
346
|
+
}
|
|
347
|
+
} catch (error) {
|
|
348
|
+
this.logger.logError(error instanceof Error ? error.message : String(error));
|
|
349
|
+
process.exit(1);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
300
352
|
|
|
301
|
-
|
|
302
|
-
|
|
353
|
+
private async executeRequests(
|
|
354
|
+
yamlFiles: string[],
|
|
355
|
+
globalConfig: GlobalConfig,
|
|
356
|
+
): Promise<ExecutionSummary> {
|
|
357
|
+
// Process YAML files and collect requests
|
|
358
|
+
const fileGroups: Array<{ file: string; requests: RequestConfig[]; config?: GlobalConfig }> =
|
|
359
|
+
[];
|
|
360
|
+
const allRequests: RequestConfig[] = [];
|
|
361
|
+
|
|
362
|
+
for (const file of yamlFiles) {
|
|
363
|
+
const { requests, config } = await this.processYamlFile(file);
|
|
364
|
+
|
|
365
|
+
const fileOutputConfig = config?.output || {};
|
|
366
|
+
const requestsWithSourceConfig = requests.map((request) => ({
|
|
367
|
+
...request,
|
|
368
|
+
sourceOutputConfig: fileOutputConfig,
|
|
369
|
+
sourceFile: file,
|
|
370
|
+
}));
|
|
371
|
+
|
|
372
|
+
fileGroups.push({ file, requests: requestsWithSourceConfig, config });
|
|
373
|
+
allRequests.push(...requestsWithSourceConfig);
|
|
374
|
+
}
|
|
303
375
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
totalDuration += fileSummary.duration;
|
|
376
|
+
const executor = new RequestExecutor(globalConfig);
|
|
377
|
+
let summary: ExecutionSummary;
|
|
307
378
|
|
|
308
|
-
|
|
379
|
+
// If multiple files, execute them with file separators for clarity
|
|
380
|
+
if (fileGroups.length > 1) {
|
|
381
|
+
const allResults: ExecutionResult[] = [];
|
|
382
|
+
let totalDuration = 0;
|
|
309
383
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
console.log();
|
|
313
|
-
}
|
|
314
|
-
}
|
|
384
|
+
for (let i = 0; i < fileGroups.length; i++) {
|
|
385
|
+
const group = fileGroups[i];
|
|
315
386
|
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
const failed = allResults.filter((r) => !r.success).length;
|
|
387
|
+
// Show file header for better organization
|
|
388
|
+
this.logger.logFileHeader(group.file, group.requests.length);
|
|
319
389
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
failed,
|
|
324
|
-
duration: totalDuration,
|
|
325
|
-
results: allResults,
|
|
326
|
-
};
|
|
390
|
+
const fileSummary = await executor.execute(group.requests);
|
|
391
|
+
allResults.push(...fileSummary.results);
|
|
392
|
+
totalDuration += fileSummary.duration;
|
|
327
393
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
summary = await executor.execute(allRequests);
|
|
394
|
+
// Add spacing between files (except for the last one)
|
|
395
|
+
if (i < fileGroups.length - 1) {
|
|
396
|
+
console.log();
|
|
397
|
+
}
|
|
333
398
|
}
|
|
334
399
|
|
|
335
|
-
//
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
400
|
+
// Create combined summary
|
|
401
|
+
const successful = allResults.filter((r) => r.success).length;
|
|
402
|
+
const failed = allResults.filter((r) => !r.success).length;
|
|
403
|
+
|
|
404
|
+
summary = {
|
|
405
|
+
total: allResults.length,
|
|
406
|
+
successful,
|
|
407
|
+
failed,
|
|
408
|
+
duration: totalDuration,
|
|
409
|
+
results: allResults,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// Show final summary
|
|
413
|
+
this.logger.logSummary(summary, true);
|
|
414
|
+
} else {
|
|
415
|
+
// Single file - use normal execution
|
|
416
|
+
summary = await executor.execute(allRequests);
|
|
341
417
|
}
|
|
418
|
+
|
|
419
|
+
return summary;
|
|
342
420
|
}
|
|
343
421
|
|
|
344
422
|
private parseArguments(args: string[]): { files: string[]; options: Record<string, unknown> } {
|
|
@@ -366,6 +444,12 @@ class CurlRunnerCLI {
|
|
|
366
444
|
options.showMetrics = true;
|
|
367
445
|
} else if (key === 'strict-exit') {
|
|
368
446
|
options.strictExit = true;
|
|
447
|
+
} else if (key === 'watch') {
|
|
448
|
+
options.watch = true;
|
|
449
|
+
} else if (key === 'watch-clear') {
|
|
450
|
+
options.watchClear = true;
|
|
451
|
+
} else if (key === 'no-watch-clear') {
|
|
452
|
+
options.watchClear = false;
|
|
369
453
|
} else if (nextArg && !nextArg.startsWith('--')) {
|
|
370
454
|
if (key === 'continue-on-error') {
|
|
371
455
|
options.continueOnError = nextArg === 'true';
|
|
@@ -397,6 +481,8 @@ class CurlRunnerCLI {
|
|
|
397
481
|
if (['minimal', 'standard', 'detailed'].includes(nextArg)) {
|
|
398
482
|
options.prettyLevel = nextArg;
|
|
399
483
|
}
|
|
484
|
+
} else if (key === 'watch-debounce') {
|
|
485
|
+
options.watchDebounce = Number.parseInt(nextArg, 10);
|
|
400
486
|
} else {
|
|
401
487
|
options[key] = nextArg;
|
|
402
488
|
}
|
|
@@ -423,6 +509,9 @@ class CurlRunnerCLI {
|
|
|
423
509
|
case 'q':
|
|
424
510
|
options.quiet = true;
|
|
425
511
|
break;
|
|
512
|
+
case 'w':
|
|
513
|
+
options.watch = true;
|
|
514
|
+
break;
|
|
426
515
|
case 'o': {
|
|
427
516
|
// Handle -o flag for output file
|
|
428
517
|
const outputArg = args[i + 1];
|
|
@@ -551,6 +640,7 @@ class CurlRunnerCLI {
|
|
|
551
640
|
output: { ...base.output, ...override.output },
|
|
552
641
|
defaults: { ...base.defaults, ...override.defaults },
|
|
553
642
|
ci: { ...base.ci, ...override.ci },
|
|
643
|
+
watch: { ...base.watch, ...override.watch },
|
|
554
644
|
};
|
|
555
645
|
}
|
|
556
646
|
|
|
@@ -633,6 +723,11 @@ ${this.logger.color('OPTIONS:', 'yellow')}
|
|
|
633
723
|
--show-metrics Include performance metrics in output
|
|
634
724
|
--version Show version
|
|
635
725
|
|
|
726
|
+
${this.logger.color('WATCH MODE:', 'yellow')}
|
|
727
|
+
-w, --watch Watch files and re-run on changes
|
|
728
|
+
--watch-debounce <ms> Debounce delay for watch mode (default: 300)
|
|
729
|
+
--no-watch-clear Don't clear screen between watch runs
|
|
730
|
+
|
|
636
731
|
${this.logger.color('CI/CD OPTIONS:', 'yellow')}
|
|
637
732
|
--strict-exit Exit with code 1 if any validation fails (for CI/CD)
|
|
638
733
|
--fail-on <count> Exit with code 1 if failures exceed this count
|
|
@@ -681,6 +776,12 @@ ${this.logger.color('EXAMPLES:', 'yellow')}
|
|
|
681
776
|
# CI/CD: Allow up to 10% failures
|
|
682
777
|
curl-runner tests/ --fail-on-percentage 10
|
|
683
778
|
|
|
779
|
+
# Watch mode - re-run on file changes
|
|
780
|
+
curl-runner api.yaml --watch
|
|
781
|
+
|
|
782
|
+
# Watch with custom debounce
|
|
783
|
+
curl-runner tests/ -w --watch-debounce 500
|
|
784
|
+
|
|
684
785
|
${this.logger.color('YAML STRUCTURE:', 'yellow')}
|
|
685
786
|
Single request:
|
|
686
787
|
request:
|
|
@@ -102,7 +102,7 @@ export class RequestExecutor {
|
|
|
102
102
|
requestLogger.logRetry(attempt, maxAttempts - 1);
|
|
103
103
|
if (config.retry?.delay) {
|
|
104
104
|
const backoff = config.retry.backoff ?? 1;
|
|
105
|
-
const delay = config.retry.delay *
|
|
105
|
+
const delay = config.retry.delay * backoff ** (attempt - 1);
|
|
106
106
|
await Bun.sleep(delay);
|
|
107
107
|
}
|
|
108
108
|
}
|
package/src/types/config.ts
CHANGED
|
@@ -210,6 +210,11 @@ export interface GlobalConfig {
|
|
|
210
210
|
* Applied to all requests unless overridden at the request level.
|
|
211
211
|
*/
|
|
212
212
|
ssl?: SSLConfig;
|
|
213
|
+
/**
|
|
214
|
+
* Watch mode configuration.
|
|
215
|
+
* Automatically re-runs requests when YAML files change.
|
|
216
|
+
*/
|
|
217
|
+
watch?: WatchConfig;
|
|
213
218
|
variables?: Record<string, string>;
|
|
214
219
|
output?: {
|
|
215
220
|
verbose?: boolean;
|
|
@@ -262,3 +267,16 @@ export interface ExecutionSummary {
|
|
|
262
267
|
* Values are stored as strings and can be referenced using ${store.variableName} syntax.
|
|
263
268
|
*/
|
|
264
269
|
export type ResponseStoreContext = Record<string, string>;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Configuration for watch mode.
|
|
273
|
+
* Watch mode automatically re-runs requests when YAML files change.
|
|
274
|
+
*/
|
|
275
|
+
export interface WatchConfig {
|
|
276
|
+
/** Enable watch mode. Default: false */
|
|
277
|
+
enabled?: boolean;
|
|
278
|
+
/** Debounce delay in milliseconds. Default: 300 */
|
|
279
|
+
debounce?: number;
|
|
280
|
+
/** Clear screen between runs. Default: true */
|
|
281
|
+
clear?: boolean;
|
|
282
|
+
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -645,4 +645,28 @@ export class Logger {
|
|
|
645
645
|
this.color(` (${requestCount} request${requestCount === 1 ? '' : 's'})`, 'dim'),
|
|
646
646
|
);
|
|
647
647
|
}
|
|
648
|
+
|
|
649
|
+
logWatch(files: string[]): void {
|
|
650
|
+
console.log();
|
|
651
|
+
console.log(
|
|
652
|
+
`${this.color('Watching for changes...', 'cyan')} ${this.color('(press Ctrl+C to stop)', 'dim')}`,
|
|
653
|
+
);
|
|
654
|
+
const fileList = files.length <= 3 ? files.join(', ') : `${files.length} files`;
|
|
655
|
+
console.log(this.color(` Files: ${fileList}`, 'dim'));
|
|
656
|
+
console.log();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
logWatchReady(): void {
|
|
660
|
+
console.log();
|
|
661
|
+
console.log(this.color('Watching for changes...', 'cyan'));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
logFileChanged(filename: string): void {
|
|
665
|
+
const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
666
|
+
console.log(this.color('-'.repeat(50), 'dim'));
|
|
667
|
+
console.log(
|
|
668
|
+
`${this.color(`[${timestamp}]`, 'dim')} File changed: ${this.color(filename, 'yellow')}`,
|
|
669
|
+
);
|
|
670
|
+
console.log();
|
|
671
|
+
}
|
|
648
672
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { FileWatcher, type WatcherOptions } from './file-watcher';
|
|
3
|
+
|
|
4
|
+
// Mock logger
|
|
5
|
+
const createMockLogger = () => ({
|
|
6
|
+
logWatch: mock(() => {}),
|
|
7
|
+
logWatchReady: mock(() => {}),
|
|
8
|
+
logFileChanged: mock(() => {}),
|
|
9
|
+
logError: mock(() => {}),
|
|
10
|
+
logWarning: mock(() => {}),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Helper to access private method for testing
|
|
14
|
+
const triggerFileChange = (watcher: FileWatcher, filename: string) => {
|
|
15
|
+
// biome-ignore lint/complexity/useLiteralKeys: accessing private method for testing
|
|
16
|
+
watcher['handleFileChange'](filename);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('FileWatcher', () => {
|
|
20
|
+
let watcher: FileWatcher;
|
|
21
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
22
|
+
let onRunMock: ReturnType<typeof mock>;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockLogger = createMockLogger();
|
|
26
|
+
onRunMock = mock(() => Promise.resolve());
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (watcher) {
|
|
31
|
+
watcher.stop();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('should debounce rapid file changes', async () => {
|
|
36
|
+
const options: WatcherOptions = {
|
|
37
|
+
files: [],
|
|
38
|
+
config: { debounce: 50 },
|
|
39
|
+
onRun: onRunMock,
|
|
40
|
+
logger: mockLogger as unknown as WatcherOptions['logger'],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
watcher = new FileWatcher(options);
|
|
44
|
+
|
|
45
|
+
// Simulate rapid file changes
|
|
46
|
+
triggerFileChange(watcher, 'test.yaml');
|
|
47
|
+
triggerFileChange(watcher, 'test.yaml');
|
|
48
|
+
triggerFileChange(watcher, 'test.yaml');
|
|
49
|
+
|
|
50
|
+
// Wait for debounce to settle
|
|
51
|
+
await Bun.sleep(100);
|
|
52
|
+
|
|
53
|
+
// Should only have been called once due to debouncing
|
|
54
|
+
// Initial run doesn't happen because we didn't call start()
|
|
55
|
+
expect(onRunMock).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('should queue runs during execution', async () => {
|
|
59
|
+
let runCount = 0;
|
|
60
|
+
const slowOnRun = mock(async () => {
|
|
61
|
+
runCount++;
|
|
62
|
+
await Bun.sleep(100);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const options: WatcherOptions = {
|
|
66
|
+
files: [],
|
|
67
|
+
config: { debounce: 10, clear: false },
|
|
68
|
+
onRun: slowOnRun,
|
|
69
|
+
logger: mockLogger as unknown as WatcherOptions['logger'],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
watcher = new FileWatcher(options);
|
|
73
|
+
|
|
74
|
+
// Start first run
|
|
75
|
+
triggerFileChange(watcher, 'test.yaml');
|
|
76
|
+
await Bun.sleep(20); // Let debounce fire
|
|
77
|
+
|
|
78
|
+
// Change during run (should be queued)
|
|
79
|
+
triggerFileChange(watcher, 'test.yaml');
|
|
80
|
+
|
|
81
|
+
// Wait for both runs to complete
|
|
82
|
+
await Bun.sleep(300);
|
|
83
|
+
|
|
84
|
+
expect(runCount).toBe(2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('should clear debounce timer on stop', () => {
|
|
88
|
+
const options: WatcherOptions = {
|
|
89
|
+
files: [],
|
|
90
|
+
config: { debounce: 1000 },
|
|
91
|
+
onRun: onRunMock,
|
|
92
|
+
logger: mockLogger as unknown as WatcherOptions['logger'],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
watcher = new FileWatcher(options);
|
|
96
|
+
|
|
97
|
+
// Trigger a change to start debounce timer
|
|
98
|
+
triggerFileChange(watcher, 'test.yaml');
|
|
99
|
+
|
|
100
|
+
// Stop should clear the timer
|
|
101
|
+
watcher.stop();
|
|
102
|
+
|
|
103
|
+
// biome-ignore lint/complexity/useLiteralKeys: accessing private field for testing
|
|
104
|
+
expect(watcher['debounceTimer']).toBeNull();
|
|
105
|
+
// biome-ignore lint/complexity/useLiteralKeys: accessing private field for testing
|
|
106
|
+
expect(watcher['watchers']).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('should use default debounce of 300ms', async () => {
|
|
110
|
+
const options: WatcherOptions = {
|
|
111
|
+
files: [],
|
|
112
|
+
config: {}, // No debounce specified
|
|
113
|
+
onRun: onRunMock,
|
|
114
|
+
logger: mockLogger as unknown as WatcherOptions['logger'],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
watcher = new FileWatcher(options);
|
|
118
|
+
|
|
119
|
+
const startTime = performance.now();
|
|
120
|
+
triggerFileChange(watcher, 'test.yaml');
|
|
121
|
+
|
|
122
|
+
// Wait less than default debounce
|
|
123
|
+
await Bun.sleep(100);
|
|
124
|
+
expect(onRunMock).not.toHaveBeenCalled();
|
|
125
|
+
|
|
126
|
+
// Wait for full debounce
|
|
127
|
+
await Bun.sleep(250);
|
|
128
|
+
expect(onRunMock).toHaveBeenCalledTimes(1);
|
|
129
|
+
|
|
130
|
+
const elapsed = performance.now() - startTime;
|
|
131
|
+
expect(elapsed).toBeGreaterThanOrEqual(300);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('should handle onRun errors gracefully', async () => {
|
|
135
|
+
const errorOnRun = mock(async () => {
|
|
136
|
+
throw new Error('Test error');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const options: WatcherOptions = {
|
|
140
|
+
files: [],
|
|
141
|
+
config: { debounce: 10 },
|
|
142
|
+
onRun: errorOnRun,
|
|
143
|
+
logger: mockLogger as unknown as WatcherOptions['logger'],
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
watcher = new FileWatcher(options);
|
|
147
|
+
|
|
148
|
+
// Should not throw
|
|
149
|
+
triggerFileChange(watcher, 'test.yaml');
|
|
150
|
+
await Bun.sleep(50);
|
|
151
|
+
|
|
152
|
+
expect(mockLogger.logError).toHaveBeenCalledWith('Test error');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('should log file changed with filename', async () => {
|
|
156
|
+
const options: WatcherOptions = {
|
|
157
|
+
files: [],
|
|
158
|
+
config: { debounce: 10, clear: false },
|
|
159
|
+
onRun: onRunMock,
|
|
160
|
+
logger: mockLogger as unknown as WatcherOptions['logger'],
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
watcher = new FileWatcher(options);
|
|
164
|
+
|
|
165
|
+
triggerFileChange(watcher, 'my-api.yaml');
|
|
166
|
+
await Bun.sleep(50);
|
|
167
|
+
|
|
168
|
+
expect(mockLogger.logFileChanged).toHaveBeenCalledWith('my-api.yaml');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('should call logWatchReady after run completes', async () => {
|
|
172
|
+
const options: WatcherOptions = {
|
|
173
|
+
files: [],
|
|
174
|
+
config: { debounce: 10, clear: false },
|
|
175
|
+
onRun: onRunMock,
|
|
176
|
+
logger: mockLogger as unknown as WatcherOptions['logger'],
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
watcher = new FileWatcher(options);
|
|
180
|
+
|
|
181
|
+
triggerFileChange(watcher, 'test.yaml');
|
|
182
|
+
await Bun.sleep(50);
|
|
183
|
+
|
|
184
|
+
expect(mockLogger.logWatchReady).toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { type FSWatcher, watch } from 'node:fs';
|
|
2
|
+
import type { WatchConfig } from '../types/config';
|
|
3
|
+
import type { Logger } from '../utils/logger';
|
|
4
|
+
|
|
5
|
+
export interface WatcherOptions {
|
|
6
|
+
files: string[];
|
|
7
|
+
config: WatchConfig;
|
|
8
|
+
onRun: () => Promise<void>;
|
|
9
|
+
logger: Logger;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class FileWatcher {
|
|
13
|
+
private watchers: FSWatcher[] = [];
|
|
14
|
+
private debounceTimer: Timer | null = null;
|
|
15
|
+
private isRunning = false;
|
|
16
|
+
private pendingRun = false;
|
|
17
|
+
private options: WatcherOptions;
|
|
18
|
+
|
|
19
|
+
constructor(options: WatcherOptions) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async start(): Promise<void> {
|
|
24
|
+
const { files, logger } = this.options;
|
|
25
|
+
|
|
26
|
+
// Initial run
|
|
27
|
+
await this.runRequests();
|
|
28
|
+
|
|
29
|
+
// Setup watchers for each file
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
try {
|
|
32
|
+
const watcher = watch(file, (event) => {
|
|
33
|
+
if (event === 'change') {
|
|
34
|
+
this.handleFileChange(file);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
watcher.on('error', (error) => {
|
|
39
|
+
logger.logWarning(`Watch error on ${file}: ${error.message}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
this.watchers.push(watcher);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
logger.logWarning(
|
|
45
|
+
`Failed to watch ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Log watching status
|
|
51
|
+
logger.logWatch(files);
|
|
52
|
+
|
|
53
|
+
// Handle graceful shutdown
|
|
54
|
+
this.setupSignalHandlers();
|
|
55
|
+
|
|
56
|
+
// Keep process alive
|
|
57
|
+
await this.keepAlive();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private handleFileChange(filename: string): void {
|
|
61
|
+
const { config, logger } = this.options;
|
|
62
|
+
const debounce = config.debounce ?? 300;
|
|
63
|
+
|
|
64
|
+
// Clear existing debounce timer
|
|
65
|
+
if (this.debounceTimer) {
|
|
66
|
+
clearTimeout(this.debounceTimer);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Debounce the run
|
|
70
|
+
this.debounceTimer = setTimeout(async () => {
|
|
71
|
+
// If already running, queue for after completion
|
|
72
|
+
if (this.isRunning) {
|
|
73
|
+
this.pendingRun = true;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (config.clear !== false) {
|
|
78
|
+
console.clear();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
logger.logFileChanged(filename);
|
|
82
|
+
await this.runRequests();
|
|
83
|
+
}, debounce);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async runRequests(): Promise<void> {
|
|
87
|
+
const { onRun, logger } = this.options;
|
|
88
|
+
|
|
89
|
+
this.isRunning = true;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await onRun();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logger.logError(error instanceof Error ? error.message : String(error));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.isRunning = false;
|
|
98
|
+
|
|
99
|
+
// Check for pending runs
|
|
100
|
+
if (this.pendingRun) {
|
|
101
|
+
this.pendingRun = false;
|
|
102
|
+
const { config } = this.options;
|
|
103
|
+
if (config.clear !== false) {
|
|
104
|
+
console.clear();
|
|
105
|
+
}
|
|
106
|
+
logger.logFileChanged('(queued change)');
|
|
107
|
+
await this.runRequests();
|
|
108
|
+
} else {
|
|
109
|
+
logger.logWatchReady();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private setupSignalHandlers(): void {
|
|
114
|
+
const cleanup = () => {
|
|
115
|
+
this.stop();
|
|
116
|
+
process.exit(0);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
process.on('SIGINT', cleanup);
|
|
120
|
+
process.on('SIGTERM', cleanup);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async keepAlive(): Promise<never> {
|
|
124
|
+
while (true) {
|
|
125
|
+
await Bun.sleep(1000);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
stop(): void {
|
|
130
|
+
for (const watcher of this.watchers) {
|
|
131
|
+
watcher.close();
|
|
132
|
+
}
|
|
133
|
+
this.watchers = [];
|
|
134
|
+
|
|
135
|
+
if (this.debounceTimer) {
|
|
136
|
+
clearTimeout(this.debounceTimer);
|
|
137
|
+
this.debounceTimer = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|