@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.
@@ -147,6 +147,11 @@ export interface RequestConfig {
147
147
  body?: JsonValue;
148
148
  responseTime?: string; // Response time validation like "< 1000", "> 500, < 2000"
149
149
  };
150
+ /**
151
+ * Snapshot configuration for this request.
152
+ * Use `true` to enable with defaults, or provide detailed config.
153
+ */
154
+ snapshot?: SnapshotConfig | boolean;
150
155
  sourceOutputConfig?: {
151
156
  verbose?: boolean;
152
157
  showHeaders?: boolean;
@@ -210,6 +215,16 @@ export interface GlobalConfig {
210
215
  * Applied to all requests unless overridden at the request level.
211
216
  */
212
217
  ssl?: SSLConfig;
218
+ /**
219
+ * Watch mode configuration.
220
+ * Automatically re-runs requests when YAML files change.
221
+ */
222
+ watch?: WatchConfig;
223
+ /**
224
+ * Snapshot testing configuration.
225
+ * Saves response snapshots and compares future runs against them.
226
+ */
227
+ snapshot?: GlobalSnapshotConfig;
213
228
  variables?: Record<string, string>;
214
229
  output?: {
215
230
  verbose?: boolean;
@@ -247,6 +262,8 @@ export interface ExecutionResult {
247
262
  firstByte?: number;
248
263
  download?: number;
249
264
  };
265
+ /** Snapshot comparison result (if snapshot testing enabled). */
266
+ snapshotResult?: SnapshotCompareResult;
250
267
  }
251
268
 
252
269
  export interface ExecutionSummary {
@@ -262,3 +279,84 @@ export interface ExecutionSummary {
262
279
  * Values are stored as strings and can be referenced using ${store.variableName} syntax.
263
280
  */
264
281
  export type ResponseStoreContext = Record<string, string>;
282
+
283
+ /**
284
+ * Configuration for watch mode.
285
+ * Watch mode automatically re-runs requests when YAML files change.
286
+ */
287
+ export interface WatchConfig {
288
+ /** Enable watch mode. Default: false */
289
+ enabled?: boolean;
290
+ /** Debounce delay in milliseconds. Default: 300 */
291
+ debounce?: number;
292
+ /** Clear screen between runs. Default: true */
293
+ clear?: boolean;
294
+ }
295
+
296
+ /**
297
+ * Configuration for snapshot testing.
298
+ * Snapshots save response data and compare future runs against them.
299
+ */
300
+ export interface SnapshotConfig {
301
+ /** Enable snapshot testing for this request. */
302
+ enabled?: boolean;
303
+ /** Custom snapshot name (defaults to request name). */
304
+ name?: string;
305
+ /** What to include in snapshot. Default: ['body'] */
306
+ include?: ('body' | 'status' | 'headers')[];
307
+ /** Paths to exclude from comparison (e.g., 'body.timestamp'). */
308
+ exclude?: string[];
309
+ /** Match rules for dynamic values (path -> '*' or 'regex:pattern'). */
310
+ match?: Record<string, string>;
311
+ }
312
+
313
+ /**
314
+ * Global snapshot configuration.
315
+ */
316
+ export interface GlobalSnapshotConfig extends SnapshotConfig {
317
+ /** Directory for snapshot files. Default: '__snapshots__' */
318
+ dir?: string;
319
+ /** Update mode: 'none' | 'all' | 'failing'. Default: 'none' */
320
+ updateMode?: 'none' | 'all' | 'failing';
321
+ /** CI mode: fail if snapshot is missing. Default: false */
322
+ ci?: boolean;
323
+ }
324
+
325
+ /**
326
+ * Stored snapshot data for a single request.
327
+ */
328
+ export interface Snapshot {
329
+ status?: number;
330
+ headers?: Record<string, string>;
331
+ body?: JsonValue;
332
+ hash: string;
333
+ updatedAt: string;
334
+ }
335
+
336
+ /**
337
+ * Snapshot file format.
338
+ */
339
+ export interface SnapshotFile {
340
+ version: number;
341
+ snapshots: Record<string, Snapshot>;
342
+ }
343
+
344
+ /**
345
+ * Result of comparing a response against a snapshot.
346
+ */
347
+ export interface SnapshotDiff {
348
+ path: string;
349
+ expected: unknown;
350
+ received: unknown;
351
+ type: 'added' | 'removed' | 'changed' | 'type_mismatch';
352
+ }
353
+
354
+ /**
355
+ * Result of snapshot comparison.
356
+ */
357
+ export interface SnapshotCompareResult {
358
+ match: boolean;
359
+ isNew: boolean;
360
+ updated: boolean;
361
+ differences: SnapshotDiff[];
362
+ }
@@ -1,3 +1,4 @@
1
+ import { SnapshotFormatter } from '../snapshot/snapshot-formatter';
1
2
  import type {
2
3
  ExecutionResult,
3
4
  ExecutionSummary,
@@ -409,6 +410,12 @@ export class Logger {
409
410
  this.logValidationErrors(result.error);
410
411
  }
411
412
 
413
+ // Show snapshot result
414
+ if (result.snapshotResult) {
415
+ console.log();
416
+ this.logSnapshotResult(result.request.name || 'Request', result.snapshotResult);
417
+ }
418
+
412
419
  console.log();
413
420
  return;
414
421
  }
@@ -532,9 +539,27 @@ export class Logger {
532
539
  this.logValidationErrors(result.error);
533
540
  }
534
541
 
542
+ // Show snapshot result
543
+ if (result.snapshotResult) {
544
+ console.log();
545
+ this.logSnapshotResult(result.request.name || 'Request', result.snapshotResult);
546
+ }
547
+
535
548
  console.log();
536
549
  }
537
550
 
551
+ /**
552
+ * Logs snapshot comparison result.
553
+ */
554
+ private logSnapshotResult(requestName: string, result: ExecutionResult['snapshotResult']): void {
555
+ if (!result) {
556
+ return;
557
+ }
558
+
559
+ const formatter = new SnapshotFormatter();
560
+ console.log(formatter.formatResult(requestName, result));
561
+ }
562
+
538
563
  logSummary(summary: ExecutionSummary, isGlobal: boolean = false): void {
539
564
  // For raw format, don't show summary
540
565
  if (this.config.format === 'raw') {
@@ -645,4 +670,28 @@ export class Logger {
645
670
  this.color(` (${requestCount} request${requestCount === 1 ? '' : 's'})`, 'dim'),
646
671
  );
647
672
  }
673
+
674
+ logWatch(files: string[]): void {
675
+ console.log();
676
+ console.log(
677
+ `${this.color('Watching for changes...', 'cyan')} ${this.color('(press Ctrl+C to stop)', 'dim')}`,
678
+ );
679
+ const fileList = files.length <= 3 ? files.join(', ') : `${files.length} files`;
680
+ console.log(this.color(` Files: ${fileList}`, 'dim'));
681
+ console.log();
682
+ }
683
+
684
+ logWatchReady(): void {
685
+ console.log();
686
+ console.log(this.color('Watching for changes...', 'cyan'));
687
+ }
688
+
689
+ logFileChanged(filename: string): void {
690
+ const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false });
691
+ console.log(this.color('-'.repeat(50), 'dim'));
692
+ console.log(
693
+ `${this.color(`[${timestamp}]`, 'dim')} File changed: ${this.color(filename, 'yellow')}`,
694
+ );
695
+ console.log();
696
+ }
648
697
  }
@@ -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
+ }