fix: proper progress and copying

This commit is contained in:
2026-02-26 17:45:17 +01:00
parent d138bd88b4
commit 2f66b51d89
2 changed files with 130 additions and 7 deletions

View File

@@ -219,7 +219,9 @@ export class PublishEngine extends EventEmitter {
exclude?: string[],
): Promise<DirectoryUploadResult> {
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 });
}
},
);

View File

@@ -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', () => {