From 411431ecc599aa811d83d445d44f64dec555aa79 Mon Sep 17 00:00:00 2001 From: Greg Priday Date: Thu, 26 Feb 2026 13:57:37 +1100 Subject: [PATCH] feat(api): add onProgress callback to copy() and scan() APIs - Add ProgressTracker utility that normalizes pipeline events to { percent, message } - Wire ProgressTracker into scan() when options.onProgress is provided - Add progress wrapping in copy() with scan=0-80% and format=80-100% split - Add monotonic guard and try/catch in copy() so callback errors are non-fatal - Export ProgressTracker from src/index.js for advanced consumers - Add TypeScript definitions for ProgressTracker, ProgressTrackerOptions - Make ProgressTrackerOptions.totalStages optional (defaults to 1) - Add 24 unit tests for ProgressTracker (lifecycle, throttle, edge cases) - Add 12 integration tests for onProgress in scan() and copy() --- src/api/copy.js | 58 +++- src/api/scan.js | 14 + src/index.js | 1 + src/utils/ProgressTracker.js | 181 ++++++++++++ tests/integration/onProgress.test.js | 192 ++++++++++++ tests/unit/utils/ProgressTracker.test.js | 358 +++++++++++++++++++++++ types/index.d.ts | 48 +++ 7 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 src/utils/ProgressTracker.js create mode 100644 tests/integration/onProgress.test.js create mode 100644 tests/unit/utils/ProgressTracker.test.js diff --git a/src/api/copy.js b/src/api/copy.js index 9f78b5b..06aee2b 100644 --- a/src/api/copy.js +++ b/src/api/copy.js @@ -26,6 +26,10 @@ import Clipboard from '../utils/clipboard.js'; * @property {ConfigManager} [config] - ConfigManager instance for isolated configuration. * If not provided, an isolated instance will be created. This enables concurrent * copy operations with different configurations. + * @property {Function} [onProgress] - Progress callback ({ percent, message }). + * Called periodically during copy with normalized progress updates (0-100%). + * Scan phase covers 0-80%, formatting 80-100%. + * @property {number} [progressThrottleMs=100] - Minimum ms between progress emissions. */ /** @@ -92,6 +96,35 @@ export async function copy(basePath, options = {}) { // This enables concurrent copy operations with different configurations const configInstance = options.config || (await ConfigManager.create()); + // Build progress wrapper: scan gets 0-80%, format gets 80-100% + const { onProgress, progressThrottleMs } = options; + let scanProgress = null; + let lastEmittedPercent = -1; + + /** + * Emit progress with a monotonic guard so percent never decreases. + * Swallows exceptions so a buggy callback never breaks the operation. + */ + const emitProgress = onProgress + ? (percent, message) => { + const clamped = Math.max(percent, lastEmittedPercent); + lastEmittedPercent = clamped; + try { + onProgress({ percent: clamped, message }); + } catch { + // Swallow callback exceptions + } + } + : null; + + if (emitProgress) { + emitProgress(0, 'Starting...'); + scanProgress = (progress) => { + // Scale scan progress to 0-80% + emitProgress(Math.round(progress.percent * 0.8), progress.message); + }; + } + // Handle dry run if (options.dryRun) { // For dry run, collect file list without content @@ -101,12 +134,18 @@ export async function copy(basePath, options = {}) { config: configInstance, includeContent: false, transform: false, + onProgress: scanProgress, + progressThrottleMs, })) { files.push(file); } const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0); + if (emitProgress) { + emitProgress(100, 'Complete'); + } + return { output: '', files: files, @@ -124,7 +163,12 @@ export async function copy(basePath, options = {}) { const scanErrors = []; try { - for await (const file of scan(basePath, { ...options, config: configInstance })) { + for await (const file of scan(basePath, { + ...options, + config: configInstance, + onProgress: scanProgress, + progressThrottleMs, + })) { files.push(file); } } catch (error) { @@ -138,6 +182,10 @@ export async function copy(basePath, options = {}) { // Calculate stats const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0); + if (emitProgress) { + emitProgress(80, 'Formatting output...'); + } + // Format output const output = await format(files, { format: options.format, @@ -149,6 +197,10 @@ export async function copy(basePath, options = {}) { prettyPrint: options.prettyPrint, }); + if (emitProgress) { + emitProgress(95, 'Finalizing...'); + } + // Build result const result = { output, @@ -192,6 +244,10 @@ export async function copy(basePath, options = {}) { } } + if (emitProgress) { + emitProgress(100, 'Complete'); + } + return result; } diff --git a/src/api/scan.js b/src/api/scan.js index 655b6b6..e6a5b09 100644 --- a/src/api/scan.js +++ b/src/api/scan.js @@ -2,6 +2,7 @@ import Pipeline from '../pipeline/Pipeline.js'; import { ValidationError } from '../utils/errors.js'; import { ConfigManager } from '../config/ConfigManager.js'; import FolderProfileLoader from '../config/FolderProfileLoader.js'; +import { ProgressTracker } from '../utils/ProgressTracker.js'; import path from 'path'; import fs from 'fs-extra'; @@ -42,6 +43,9 @@ import fs from 'fs-extra'; * @property {ConfigManager} [config] - ConfigManager instance for isolated configuration. * If not provided, an isolated instance will be created. This enables concurrent * scan operations with different configurations. + * @property {Function} [onProgress] - Progress callback ({ percent, message }). + * Called periodically during scanning with normalized progress updates (0-100%). + * @property {number} [progressThrottleMs=100] - Minimum ms between progress emissions. */ /** @@ -291,6 +295,16 @@ export async function* scan(basePath, options = {}) { pipeline.through(stages); + // Setup progress tracking + if (options.onProgress) { + const tracker = new ProgressTracker({ + totalStages: stages.length, + onProgress: options.onProgress, + throttleMs: options.progressThrottleMs ?? 100, + }); + tracker.attach(pipeline); + } + // Setup abort handler with cleanup let abortHandler = null; if (options.signal) { diff --git a/src/index.js b/src/index.js index e06424b..ded1747 100644 --- a/src/index.js +++ b/src/index.js @@ -22,6 +22,7 @@ export { default as Pipeline } from './pipeline/Pipeline.js'; export { default as Stage } from './pipeline/Stage.js'; export { default as TransformerRegistry } from './transforms/TransformerRegistry.js'; export { default as BaseTransformer } from './transforms/BaseTransformer.js'; +export { ProgressTracker } from './utils/ProgressTracker.js'; // Configuration utilities export { config, configAsync, ConfigManager } from './config/ConfigManager.js'; diff --git a/src/utils/ProgressTracker.js b/src/utils/ProgressTracker.js new file mode 100644 index 0000000..16f1afc --- /dev/null +++ b/src/utils/ProgressTracker.js @@ -0,0 +1,181 @@ +/** + * Normalizes pipeline events into simple progress updates. + * + * Translates detailed pipeline events (stage:start, stage:complete, file:batch, + * stage:progress) into a simple { percent, message } format for UI consumers. + * + * Progress guarantees: + * - Always starts at 0% + * - Always ends at 100% on success + * - Monotonically increasing (never goes backward) + * - Throttled to avoid overwhelming UI (default 100ms) + */ +export class ProgressTracker { + /** + * @param {Object} options + * @param {number} options.totalStages - Total number of pipeline stages + * @param {Function} [options.onProgress] - Progress callback ({ percent, message }) + * @param {number} [options.throttleMs=100] - Minimum ms between emissions + */ + constructor({ totalStages, onProgress, throttleMs = 100 } = {}) { + this.totalStages = totalStages || 1; + this.onProgress = onProgress || (() => {}); + this.throttleMs = throttleMs; + + this.completedStages = 0; + this.currentStageIndex = -1; + this.currentStageProgress = 0; + this.lastPercent = -1; + this.lastEmitTime = 0; + this.started = false; + this.finished = false; + } + + /** + * Attach event listeners to a pipeline instance. + * @param {import('../pipeline/Pipeline.js').default} pipeline + */ + attach(pipeline) { + pipeline.on('pipeline:start', () => { + this._emitForced({ percent: 0, message: 'Starting...' }); + this.started = true; + }); + + pipeline.on('stage:start', (data) => { + this.currentStageIndex = data.index; + this.currentStageProgress = 0; + + const percent = this._calculatePercent(); + this._emit({ percent, message: `${this._formatStageName(data.stage)}...` }); + }); + + pipeline.on('stage:progress', (data) => { + this.currentStageProgress = data.progress || 0; + + const percent = this._calculatePercent(); + const message = data.message || `${this._formatStageName(data.stage)}...`; + this._emit({ percent, message }); + }); + + pipeline.on('file:batch', (data) => { + const percent = this._calculatePercent(); + const message = data.lastFile + ? `Processing ${data.lastFile}` + : `Processed ${data.count} files`; + this._emit({ percent, message }); + }); + + pipeline.on('stage:complete', (data) => { + this.completedStages = data.index + 1; + this.currentStageProgress = 0; + + const percent = this._calculatePercent(); + this._emit({ + percent, + message: `Completed ${this._formatStageName(data.stage)}`, + }); + }); + + pipeline.on('pipeline:complete', () => { + this._emitForced({ percent: 100, message: 'Complete' }); + this.finished = true; + }); + + pipeline.on('pipeline:error', () => { + // On error, emit final progress at whatever we reached + if (!this.finished) { + const percent = this._calculatePercent(); + this._emitForced({ percent, message: 'Error occurred' }); + this.finished = true; + } + }); + } + + /** + * Calculate current overall progress percentage. + * @returns {number} Progress 0-99 (100 is only emitted on pipeline:complete) + * @private + */ + _calculatePercent() { + const stagePercent = (this.completedStages / this.totalStages) * 100; + const withinStagePercent = (this.currentStageProgress / 100 / this.totalStages) * 100; + return Math.min(Math.round(stagePercent + withinStagePercent), 99); + } + + /** + * Format a stage class name into a human-readable message. + * @param {string} stageName + * @returns {string} + * @private + */ + _formatStageName(stageName) { + // Convert "FileDiscoveryStage" -> "Discovering files" + // Convert "ProfileFilterStage" -> "Filtering by profile" + const stageMessages = { + FileDiscoveryStage: 'Discovering files', + AlwaysIncludeStage: 'Including required files', + GitFilterStage: 'Filtering by git status', + ProfileFilterStage: 'Applying filters', + DeduplicateFilesStage: 'Removing duplicates', + SortFilesStage: 'Sorting files', + FileLoadingStage: 'Loading file contents', + TransformStage: 'Transforming files', + CharLimitStage: 'Applying character limits', + SecretsGuardStage: 'Scanning for secrets', + InstructionsStage: 'Processing instructions', + LimitStage: 'Applying limits', + OutputFormattingStage: 'Formatting output', + StreamingOutputStage: 'Streaming output', + }; + + return stageMessages[stageName] || stageName; + } + + /** + * Emit progress if throttle window has passed and percent has increased. + * @param {{ percent: number, message: string }} progress + * @private + */ + _emit(progress) { + // Enforce monotonic progress + if (progress.percent <= this.lastPercent) { + progress = { ...progress, percent: this.lastPercent }; + } + + const now = Date.now(); + if (now - this.lastEmitTime < this.throttleMs) { + return; + } + + this.lastPercent = progress.percent; + this.lastEmitTime = now; + try { + this.onProgress(progress); + } catch { + // Swallow callback exceptions — progress tracking must not fail the operation + } + } + + /** + * Emit progress unconditionally (bypasses throttle). + * Used for start (0%) and complete (100%) events. + * @param {{ percent: number, message: string }} progress + * @private + */ + _emitForced(progress) { + // Enforce monotonic progress + if (progress.percent < this.lastPercent) { + progress = { ...progress, percent: this.lastPercent }; + } + + this.lastPercent = progress.percent; + this.lastEmitTime = Date.now(); + try { + this.onProgress(progress); + } catch { + // Swallow callback exceptions — progress tracking must not fail the operation + } + } +} + +export default ProgressTracker; diff --git a/tests/integration/onProgress.test.js b/tests/integration/onProgress.test.js new file mode 100644 index 0000000..fc313ca --- /dev/null +++ b/tests/integration/onProgress.test.js @@ -0,0 +1,192 @@ +// Unmock fs-extra for these tests to use real filesystem +jest.unmock('fs-extra'); + +import { scan, copy } from '../../src/index.js'; +import path from 'path'; + +const testDir = path.resolve(process.cwd(), 'tests/fixtures/simple-project'); + +describe('onProgress callback integration', () => { + describe('scan() with onProgress', () => { + it('receives progress updates from 0 to 100', async () => { + const updates = []; + + const files = []; + for await (const file of scan(testDir, { + onProgress: (progress) => updates.push(progress), + progressThrottleMs: 0, + })) { + files.push(file); + } + + expect(files.length).toBeGreaterThan(0); + expect(updates.length).toBeGreaterThan(0); + + // Should start at 0% + expect(updates[0].percent).toBe(0); + + // Should end at 100% + expect(updates[updates.length - 1].percent).toBe(100); + }); + + it('reports monotonically increasing progress', async () => { + const updates = []; + + for await (const _file of scan(testDir, { + onProgress: (progress) => updates.push(progress), + progressThrottleMs: 0, + })) { + // consume all files + } + + for (let i = 1; i < updates.length; i++) { + expect(updates[i].percent).toBeGreaterThanOrEqual(updates[i - 1].percent); + } + }); + + it('includes descriptive messages', async () => { + const updates = []; + + for await (const _file of scan(testDir, { + onProgress: (progress) => updates.push(progress), + progressThrottleMs: 0, + })) { + // consume all files + } + + // Every update should have a message + updates.forEach((u) => { + expect(u.message).toBeDefined(); + expect(typeof u.message).toBe('string'); + expect(u.message.length).toBeGreaterThan(0); + }); + }); + + it('works with no-op when onProgress not provided', async () => { + // Should work without any errors when onProgress is omitted + const files = []; + for await (const file of scan(testDir)) { + files.push(file); + } + expect(files.length).toBeGreaterThan(0); + }); + }); + + describe('copy() with onProgress', () => { + it('receives progress updates from 0 to 100', async () => { + const updates = []; + + const result = await copy(testDir, { + onProgress: (progress) => updates.push(progress), + progressThrottleMs: 0, + }); + + expect(result.files.length).toBeGreaterThan(0); + expect(updates.length).toBeGreaterThan(0); + + // Should start at 0% + expect(updates[0].percent).toBe(0); + + // Should end at 100% + expect(updates[updates.length - 1].percent).toBe(100); + }); + + it('reports monotonically increasing progress', async () => { + const updates = []; + + await copy(testDir, { + onProgress: (progress) => updates.push(progress), + progressThrottleMs: 0, + }); + + for (let i = 1; i < updates.length; i++) { + expect(updates[i].percent).toBeGreaterThanOrEqual(updates[i - 1].percent); + } + }); + + it('includes formatting progress', async () => { + const updates = []; + + await copy(testDir, { + onProgress: (progress) => updates.push(progress), + progressThrottleMs: 0, + }); + + // Should have a "Formatting output..." message + const formatMsg = updates.find((u) => u.message === 'Formatting output...'); + expect(formatMsg).toBeDefined(); + expect(formatMsg.percent).toBe(80); + }); + + it('works with dry run', async () => { + const updates = []; + + const result = await copy(testDir, { + dryRun: true, + onProgress: (progress) => updates.push(progress), + progressThrottleMs: 0, + }); + + expect(result.stats.dryRun).toBe(true); + expect(updates.length).toBeGreaterThan(0); + expect(updates[0].percent).toBe(0); + expect(updates[updates.length - 1].percent).toBe(100); + }); + + it('works without onProgress callback', async () => { + // Should work without any errors when onProgress is omitted + const result = await copy(testDir); + expect(result.files.length).toBeGreaterThan(0); + }); + + it('respects progressThrottleMs option', async () => { + const updates = []; + + await copy(testDir, { + onProgress: (progress) => updates.push({ ...progress, time: Date.now() }), + progressThrottleMs: 50, + }); + + // Should have received some updates but not too many + expect(updates.length).toBeGreaterThan(0); + expect(updates[updates.length - 1].percent).toBe(100); + }); + }); + + describe('edge cases', () => { + it('handles empty result set with progress — always emits start and complete', async () => { + const updates = []; + + // Use a restrictive file count limit to trigger a near-empty run + try { + for await (const _file of scan(testDir, { + maxFileCount: 1, + onProgress: (progress) => updates.push(progress), + progressThrottleMs: 0, + })) { + // consume + } + } catch { + // May throw on limit exhaustion — that's fine + } + + // Must always emit at least the pipeline:start (0%) event + expect(updates.length).toBeGreaterThan(0); + expect(updates[0].percent).toBe(0); + }); + + it('progress percent values are always between 0 and 100', async () => { + const updates = []; + + await copy(testDir, { + onProgress: (progress) => updates.push(progress), + progressThrottleMs: 0, + }); + + updates.forEach((u) => { + expect(u.percent).toBeGreaterThanOrEqual(0); + expect(u.percent).toBeLessThanOrEqual(100); + }); + }); + }); +}); diff --git a/tests/unit/utils/ProgressTracker.test.js b/tests/unit/utils/ProgressTracker.test.js new file mode 100644 index 0000000..de61bf6 --- /dev/null +++ b/tests/unit/utils/ProgressTracker.test.js @@ -0,0 +1,358 @@ +import { EventEmitter } from 'events'; +import { ProgressTracker } from '../../../src/utils/ProgressTracker.js'; + +/** + * Create a mock pipeline (EventEmitter) for testing. + */ +function createMockPipeline() { + return new EventEmitter(); +} + +describe('ProgressTracker', () => { + let pipeline; + let updates; + let tracker; + + beforeEach(() => { + pipeline = createMockPipeline(); + updates = []; + }); + + afterEach(() => { + pipeline.removeAllListeners(); + }); + + describe('constructor', () => { + it('accepts totalStages and onProgress', () => { + tracker = new ProgressTracker({ + totalStages: 5, + onProgress: () => {}, + }); + expect(tracker.totalStages).toBe(5); + }); + + it('defaults onProgress to no-op when not provided', () => { + tracker = new ProgressTracker({ totalStages: 3 }); + // Should not throw + expect(() => tracker.onProgress({ percent: 0, message: '' })).not.toThrow(); + }); + + it('defaults throttleMs to 100', () => { + tracker = new ProgressTracker({ totalStages: 1 }); + expect(tracker.throttleMs).toBe(100); + }); + + it('accepts custom throttleMs', () => { + tracker = new ProgressTracker({ totalStages: 1, throttleMs: 50 }); + expect(tracker.throttleMs).toBe(50); + }); + }); + + describe('attach()', () => { + it('listens to pipeline events after attach', () => { + tracker = new ProgressTracker({ + totalStages: 2, + onProgress: (p) => updates.push(p), + throttleMs: 0, + }); + tracker.attach(pipeline); + + pipeline.emit('pipeline:start', {}); + expect(updates.length).toBe(1); + expect(updates[0]).toEqual({ percent: 0, message: 'Starting...' }); + }); + }); + + describe('progress lifecycle', () => { + beforeEach(() => { + tracker = new ProgressTracker({ + totalStages: 2, + onProgress: (p) => updates.push(p), + throttleMs: 0, + }); + tracker.attach(pipeline); + }); + + it('emits 0% on pipeline:start', () => { + pipeline.emit('pipeline:start', {}); + expect(updates[0]).toEqual({ percent: 0, message: 'Starting...' }); + }); + + it('emits 100% on pipeline:complete', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('pipeline:complete', {}); + const last = updates[updates.length - 1]; + expect(last.percent).toBe(100); + expect(last.message).toBe('Complete'); + }); + + it('reports progress on stage:start', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:start', { stage: 'FileDiscoveryStage', index: 0 }); + const stageStart = updates.find((u) => u.message === 'Discovering files...'); + expect(stageStart).toBeDefined(); + expect(stageStart.percent).toBe(0); + }); + + it('reports progress on stage:complete', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:complete', { stage: 'FileDiscoveryStage', index: 0 }); + const stageComplete = updates.find((u) => u.message === 'Completed Discovering files'); + expect(stageComplete).toBeDefined(); + expect(stageComplete.percent).toBe(50); // 1/2 stages complete + }); + + it('reaches 100% after all stages and pipeline:complete', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:start', { stage: 'FileDiscoveryStage', index: 0 }); + pipeline.emit('stage:complete', { stage: 'FileDiscoveryStage', index: 0 }); + pipeline.emit('stage:start', { stage: 'FileLoadingStage', index: 1 }); + pipeline.emit('stage:complete', { stage: 'FileLoadingStage', index: 1 }); + pipeline.emit('pipeline:complete', {}); + + const last = updates[updates.length - 1]; + expect(last.percent).toBe(100); + }); + + it('reports file:batch events with file path', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:start', { stage: 'FileDiscoveryStage', index: 0 }); + pipeline.emit('file:batch', { + stage: 'FileDiscoveryStage', + count: 50, + lastFile: 'src/index.js', + action: 'discovered', + }); + + const batchUpdate = updates.find((u) => u.message === 'Processing src/index.js'); + expect(batchUpdate).toBeDefined(); + }); + + it('reports stage:progress events', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:start', { stage: 'TransformStage', index: 0 }); + pipeline.emit('stage:progress', { + stage: 'TransformStage', + progress: 50, + message: 'Transforming 50 files...', + }); + + const progressUpdate = updates.find((u) => u.message === 'Transforming 50 files...'); + expect(progressUpdate).toBeDefined(); + expect(progressUpdate.percent).toBe(25); // 50% of first stage = 50/100 * 1/2 = 25% + }); + }); + + describe('monotonic progress', () => { + beforeEach(() => { + tracker = new ProgressTracker({ + totalStages: 4, + onProgress: (p) => updates.push(p), + throttleMs: 0, + }); + tracker.attach(pipeline); + }); + + it('never emits a percent lower than a previous one', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:start', { stage: 'Stage1', index: 0 }); + pipeline.emit('stage:complete', { stage: 'Stage1', index: 0 }); + pipeline.emit('stage:start', { stage: 'Stage2', index: 1 }); + pipeline.emit('stage:complete', { stage: 'Stage2', index: 1 }); + pipeline.emit('stage:start', { stage: 'Stage3', index: 2 }); + pipeline.emit('stage:complete', { stage: 'Stage3', index: 2 }); + pipeline.emit('stage:start', { stage: 'Stage4', index: 3 }); + pipeline.emit('stage:complete', { stage: 'Stage4', index: 3 }); + pipeline.emit('pipeline:complete', {}); + + for (let i = 1; i < updates.length; i++) { + expect(updates[i].percent).toBeGreaterThanOrEqual(updates[i - 1].percent); + } + }); + }); + + describe('throttling', () => { + it('throttles rapid emissions', () => { + tracker = new ProgressTracker({ + totalStages: 1, + onProgress: (p) => updates.push(p), + throttleMs: 500, // High throttle for testing + }); + tracker.attach(pipeline); + + pipeline.emit('pipeline:start', {}); // Forced, always emitted + pipeline.emit('stage:start', { stage: 'Stage1', index: 0 }); + pipeline.emit('file:batch', { stage: 'Stage1', count: 1, lastFile: 'a.js' }); + pipeline.emit('file:batch', { stage: 'Stage1', count: 2, lastFile: 'b.js' }); + pipeline.emit('file:batch', { stage: 'Stage1', count: 3, lastFile: 'c.js' }); + pipeline.emit('file:batch', { stage: 'Stage1', count: 4, lastFile: 'd.js' }); + + // pipeline:start is forced, stage:start gets through, but subsequent + // rapid file:batch events within 500ms should be throttled + expect(updates.length).toBeLessThan(6); + }); + + it('always emits start (0%) and complete (100%) regardless of throttle', () => { + tracker = new ProgressTracker({ + totalStages: 1, + onProgress: (p) => updates.push(p), + throttleMs: 999999, // Very high throttle + }); + tracker.attach(pipeline); + + pipeline.emit('pipeline:start', {}); + pipeline.emit('pipeline:complete', {}); + + expect(updates.length).toBe(2); + expect(updates[0].percent).toBe(0); + expect(updates[1].percent).toBe(100); + }); + }); + + describe('stage name formatting', () => { + beforeEach(() => { + tracker = new ProgressTracker({ + totalStages: 1, + onProgress: (p) => updates.push(p), + throttleMs: 0, + }); + tracker.attach(pipeline); + }); + + it('formats known stage names', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:start', { stage: 'FileDiscoveryStage', index: 0 }); + expect(updates[1].message).toBe('Discovering files...'); + }); + + it('passes through unknown stage names', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:start', { stage: 'CustomStage', index: 0 }); + expect(updates[1].message).toBe('CustomStage...'); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + tracker = new ProgressTracker({ + totalStages: 2, + onProgress: (p) => updates.push(p), + throttleMs: 0, + }); + tracker.attach(pipeline); + }); + + it('file:batch without lastFile uses count message', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:start', { stage: 'FileDiscoveryStage', index: 0 }); + pipeline.emit('file:batch', { + stage: 'FileDiscoveryStage', + count: 42, + lastFile: null, + action: 'discovered', + }); + + const batchUpdate = updates.find((u) => u.message === 'Processed 42 files'); + expect(batchUpdate).toBeDefined(); + }); + + it('stage:progress without message uses stage name', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:start', { stage: 'TransformStage', index: 0 }); + pipeline.emit('stage:progress', { + stage: 'TransformStage', + progress: 50, + // message intentionally omitted + }); + + const progressUpdate = updates.find((u) => u.message === 'Transforming files...'); + expect(progressUpdate).toBeDefined(); + }); + + it('swallows exceptions thrown by onProgress callback', () => { + const throwingTracker = new ProgressTracker({ + totalStages: 1, + onProgress: () => { + throw new Error('callback error'); + }, + throttleMs: 0, + }); + const mockPipeline = new EventEmitter(); + throwingTracker.attach(mockPipeline); + + // Should not throw + expect(() => { + mockPipeline.emit('pipeline:start', {}); + mockPipeline.emit('pipeline:complete', {}); + }).not.toThrow(); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + tracker = new ProgressTracker({ + totalStages: 3, + onProgress: (p) => updates.push(p), + throttleMs: 0, + }); + tracker.attach(pipeline); + }); + + it('emits on pipeline:error', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:complete', { stage: 'Stage1', index: 0 }); + pipeline.emit('pipeline:error', { error: new Error('fail') }); + + const errorUpdate = updates.find((u) => u.message === 'Error occurred'); + expect(errorUpdate).toBeDefined(); + }); + + it('does not emit after pipeline has already completed', () => { + pipeline.emit('pipeline:start', {}); + pipeline.emit('pipeline:complete', {}); + const countAfterComplete = updates.length; + + pipeline.emit('pipeline:error', { error: new Error('late error') }); + // No additional update since already finished + expect(updates.length).toBe(countAfterComplete); + }); + }); + + describe('percent calculation accuracy', () => { + it('calculates correct percent for 3-stage pipeline', () => { + tracker = new ProgressTracker({ + totalStages: 3, + onProgress: (p) => updates.push(p), + throttleMs: 0, + }); + tracker.attach(pipeline); + + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:complete', { stage: 'S1', index: 0 }); + pipeline.emit('stage:complete', { stage: 'S2', index: 1 }); + pipeline.emit('stage:complete', { stage: 'S3', index: 2 }); + pipeline.emit('pipeline:complete', {}); + + // After 1 stage: 33%, after 2: 67%, after 3: 99% (capped), then 100% + const percents = updates.map((u) => u.percent); + expect(percents[0]).toBe(0); + expect(percents[percents.length - 1]).toBe(100); + }); + + it('caps progress at 99 before pipeline:complete', () => { + tracker = new ProgressTracker({ + totalStages: 1, + onProgress: (p) => updates.push(p), + throttleMs: 0, + }); + tracker.attach(pipeline); + + pipeline.emit('pipeline:start', {}); + pipeline.emit('stage:complete', { stage: 'S1', index: 0 }); + + // After the only stage completes, percent should be 99 (not 100) + const stageCompleteUpdate = updates.find((u) => u.message === 'Completed S1'); + expect(stageCompleteUpdate.percent).toBeLessThanOrEqual(99); + }); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 9bffa9f..eebc5af 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -926,6 +926,54 @@ export class Stage { protected formatBytes(bytes: number): string; } +// ============================================================================ +// Progress Tracker +// ============================================================================ + +/** + * Options for constructing a ProgressTracker + */ +export interface ProgressTrackerOptions { + /** Total number of pipeline stages (default: 1) */ + totalStages?: number; + /** Progress callback function */ + onProgress?: ProgressCallback; + /** Minimum milliseconds between throttled emissions (default: 100) */ + throttleMs?: number; +} + +/** + * Normalizes pipeline events into simple progress updates. + * + * Translates detailed pipeline events (stage:start, stage:complete, file:batch, + * stage:progress) into a simple { percent, message } format for UI consumers. + * + * Progress guarantees: + * - Always starts at 0% + * - Always ends at 100% on success + * - Monotonically increasing (never goes backward) + * - Throttled to avoid overwhelming UI (default 100ms) + */ +export class ProgressTracker { + /** Total number of pipeline stages */ + totalStages: number; + /** Progress callback function */ + onProgress: ProgressCallback; + /** Throttle interval in milliseconds */ + throttleMs: number; + + /** Create a new ProgressTracker instance */ + constructor(options?: ProgressTrackerOptions); + + /** + * Attach event listeners to a pipeline instance. + * Once attached, the tracker will listen to pipeline events and + * invoke the onProgress callback with normalized progress updates. + * @param pipeline - Pipeline instance to track + */ + attach(pipeline: Pipeline): void; +} + // ============================================================================ // Transformer Classes // ============================================================================