diff --git a/src/main/engine/PublishEngine.ts b/src/main/engine/PublishEngine.ts index 2e161a0..7bdeab3 100644 --- a/src/main/engine/PublishEngine.ts +++ b/src/main/engine/PublishEngine.ts @@ -219,7 +219,9 @@ export class PublishEngine extends EventEmitter { exclude?: string[], ): Promise { return new Promise((resolve, reject) => { - onProgress(0, 'Starting rsync...'); + onProgress(0, `Starting rsync → ${dest}`); + let filesTransferred = 0; + rsync( { src, @@ -227,17 +229,31 @@ export class PublishEngine extends EventEmitter { ssh: true, recursive: true, times: true, - args: ['--update', '--compress'], + args: ['--update', '--compress', '--verbose'], exclude: exclude || [], + onStdout: (data: string | Buffer) => { + const lines = data.toString().split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('sending ')) continue; + if (/\bbytes\b/.test(trimmed)) continue; + if (/total size is/.test(trimmed)) continue; + if (/speedup is/.test(trimmed)) continue; + filesTransferred++; + onProgress( + Math.min(filesTransferred, 99), + `${trimmed} → ${dest}`, + ); + } + }, }, - (error, stdout, _stderr, _cmd) => { + (error, _stdout, _stderr, _cmd) => { if (error) { reject(error); } else { - const lines = stdout.trim().split('\n').filter((l: string) => l.length > 0); - const count = lines.length; - onProgress(100, `rsync complete: ${count} files transferred`); - resolve({ filesUploaded: count, filesSkipped: 0 }); + onProgress(100, `rsync complete: ${filesTransferred} files transferred`); + resolve({ filesUploaded: filesTransferred, filesSkipped: 0 }); } }, ); diff --git a/tests/engine/PublishEngine.test.ts b/tests/engine/PublishEngine.test.ts index dae292d..df750a3 100644 --- a/tests/engine/PublishEngine.test.ts +++ b/tests/engine/PublishEngine.test.ts @@ -348,6 +348,113 @@ describe('PublishEngine', () => { }); }); + // ── rsync live progress via onStdout ────────────────────────────────── + + describe('rsync mode – live progress', () => { + const rsyncCredentials: PublishCredentials = { ...defaultCredentials, sshMode: 'rsync' }; + + it('should pass --verbose flag so rsync reports each file', async () => { + await engine.uploadHtml(rsyncCredentials, vi.fn()); + const [options] = mockRsync.mock.calls[0]; + expect(options.args).toContain('--verbose'); + }); + + it('should provide onStdout callback to rsync options', async () => { + await engine.uploadHtml(rsyncCredentials, vi.fn()); + const [options] = mockRsync.mock.calls[0]; + expect(typeof options.onStdout).toBe('function'); + }); + + it('should report filenames from rsync stdout as progress messages', async () => { + mockRsync.mockImplementation((options: any, callback: any) => { + if (options.onStdout) { + options.onStdout('sending incremental file list\n'); + options.onStdout('index.html\n'); + options.onStdout('about.html\n'); + options.onStdout('css/style.css\n'); + } + callback(null, 'index.html\nabout.html\ncss/style.css\n', '', 'rsync cmd'); + }); + + const onProgress = vi.fn(); + await engine.uploadHtml(rsyncCredentials, onProgress); + + const messages = onProgress.mock.calls.map(([, m]: [number, string]) => m); + expect(messages.some(m => m.includes('index.html'))).toBe(true); + expect(messages.some(m => m.includes('about.html'))).toBe(true); + expect(messages.some(m => m.includes('css/style.css'))).toBe(true); + }); + + it('should show the remote destination in progress messages', async () => { + mockRsync.mockImplementation((options: any, callback: any) => { + if (options.onStdout) { + options.onStdout('index.html\n'); + } + callback(null, 'index.html\n', '', 'rsync cmd'); + }); + + const onProgress = vi.fn(); + await engine.uploadHtml(rsyncCredentials, onProgress); + + const messages = onProgress.mock.calls.map(([, m]: [number, string]) => m); + expect(messages.some(m => m.includes('example.com'))).toBe(true); + }); + + it('should skip non-file lines from rsync output', async () => { + mockRsync.mockImplementation((options: any, callback: any) => { + if (options.onStdout) { + options.onStdout('sending incremental file list\n'); + options.onStdout('index.html\n'); + options.onStdout('\n'); + options.onStdout('sent 1234 bytes received 56 bytes\n'); + options.onStdout('total size is 9876 speedup is 2.50\n'); + } + callback(null, 'index.html\n', '', 'rsync cmd'); + }); + + const onProgress = vi.fn(); + await engine.uploadHtml(rsyncCredentials, onProgress); + + const messages = onProgress.mock.calls.map(([, m]: [number, string]) => m); + expect(messages.some(m => m.includes('sending incremental'))).toBe(false); + expect(messages.some(m => m.includes('sent 1234'))).toBe(false); + expect(messages.some(m => m.includes('total size'))).toBe(false); + }); + + it('should handle multi-line chunks from onStdout', async () => { + mockRsync.mockImplementation((options: any, callback: any) => { + if (options.onStdout) { + // rsync may send multiple files in a single stdout chunk + options.onStdout('file1.html\nfile2.html\nfile3.html\n'); + } + callback(null, 'file1.html\nfile2.html\nfile3.html\n', '', 'rsync cmd'); + }); + + const onProgress = vi.fn(); + await engine.uploadHtml(rsyncCredentials, onProgress); + + const messages = onProgress.mock.calls.map(([, m]: [number, string]) => m); + expect(messages.some(m => m.includes('file1.html'))).toBe(true); + expect(messages.some(m => m.includes('file2.html'))).toBe(true); + expect(messages.some(m => m.includes('file3.html'))).toBe(true); + }); + + it('should reach 100% on completion with file count', async () => { + mockRsync.mockImplementation((options: any, callback: any) => { + if (options.onStdout) { + options.onStdout('file1.html\nfile2.html\n'); + } + callback(null, 'file1.html\nfile2.html\n', '', 'rsync cmd'); + }); + + const onProgress = vi.fn(); + await engine.uploadHtml(rsyncCredentials, onProgress); + + const last = onProgress.mock.calls[onProgress.mock.calls.length - 1]; + expect(last[0]).toBe(100); + }); + }); + // ── per-file progress across methods ────────────────────────────────── describe('per-file progress', () => {