From d138bd88b47eaebdff029ff4b36b489c025568f9 Mon Sep 17 00:00:00 2001 From: hugo Date: Thu, 26 Feb 2026 17:15:31 +0100 Subject: [PATCH] fix: multiple tasks --- src/main/engine/PublishEngine.ts | 323 +++++++++++-------------- src/main/engine/index.ts | 2 +- src/main/ipc/publishHandlers.ts | 46 +++- tests/engine/PublishEngine.test.ts | 362 +++++++++++++++-------------- 4 files changed, 358 insertions(+), 375 deletions(-) diff --git a/src/main/engine/PublishEngine.ts b/src/main/engine/PublishEngine.ts index 10e38d7..2e161a0 100644 --- a/src/main/engine/PublishEngine.ts +++ b/src/main/engine/PublishEngine.ts @@ -12,10 +12,8 @@ export interface PublishCredentials { sshMode: 'scp' | 'rsync'; } -export interface PublishResult { - htmlFilesUploaded: number; - thumbnailFilesUploaded: number; - mediaFilesUploaded: number; +export interface DirectoryUploadResult { + filesUploaded: number; filesSkipped: number; } @@ -37,49 +35,101 @@ export class PublishEngine extends EventEmitter { this.dataDir = dataDir; } - async uploadSite( + // ── Public upload methods (one per directory, run as parallel tasks) ─── + + /** + * Upload html/ → remote root. + * Requires html/ to exist (site must be rendered first). + */ + async uploadHtml( credentials: PublishCredentials, onProgress: ProgressCallback, - ): Promise { - if (!this.dataDir || !this.projectId) { - throw new Error('No project context set'); - } + ): Promise { + this.ensureProjectContext(); this.validateCredentials(credentials); - const htmlDir = path.join(this.dataDir, 'html'); - const thumbnailsDir = path.join(this.dataDir, 'thumbnails'); - const mediaDir = path.join(this.dataDir, 'media'); - - // Verify the generated site exists + const htmlDir = path.join(this.dataDir!, 'html'); await this.ensureDirectoryExists(htmlDir, 'Generated site not found. Please render the site first.'); - const result: PublishResult = { - htmlFilesUploaded: 0, - thumbnailFilesUploaded: 0, - mediaFilesUploaded: 0, - filesSkipped: 0, - }; - if (credentials.sshMode === 'rsync') { - await this.uploadViaRsync(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress); - } else { - await this.uploadViaScp(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress); + return this.rsyncDirectory( + htmlDir + '/', + this.rsyncDest(credentials, '/'), + onProgress, + ); } - - onProgress(100, 'Upload complete'); - return result; + return this.scpUploadDir(credentials, htmlDir, credentials.sshRemotePath, onProgress); } - // ── SCP mode ────────────────────────────────────────────────────────────── - - private async uploadViaScp( + /** + * Upload thumbnails/ → remote/thumbnails/. + * Silently returns zero counts if thumbnails/ does not exist. + */ + async uploadThumbnails( credentials: PublishCredentials, - htmlDir: string, - thumbnailsDir: string, - mediaDir: string, - result: PublishResult, onProgress: ProgressCallback, - ): Promise { + ): Promise { + this.ensureProjectContext(); + this.validateCredentials(credentials); + + const thumbnailsDir = path.join(this.dataDir!, 'thumbnails'); + if (!(await this.directoryExists(thumbnailsDir))) { + onProgress(100, 'No thumbnails to upload'); + return { filesUploaded: 0, filesSkipped: 0 }; + } + + const remotePath = path.posix.join(credentials.sshRemotePath, 'thumbnails'); + if (credentials.sshMode === 'rsync') { + return this.rsyncDirectory( + thumbnailsDir + '/', + this.rsyncDest(credentials, '/thumbnails/'), + onProgress, + ); + } + return this.scpUploadDir(credentials, thumbnailsDir, remotePath, onProgress); + } + + /** + * Upload media/ → remote/media/, excluding .meta sidecar files. + * Silently returns zero counts if media/ does not exist. + */ + async uploadMedia( + credentials: PublishCredentials, + onProgress: ProgressCallback, + ): Promise { + this.ensureProjectContext(); + this.validateCredentials(credentials); + + const mediaDir = path.join(this.dataDir!, 'media'); + if (!(await this.directoryExists(mediaDir))) { + onProgress(100, 'No media to upload'); + return { filesUploaded: 0, filesSkipped: 0 }; + } + + const remotePath = path.posix.join(credentials.sshRemotePath, 'media'); + if (credentials.sshMode === 'rsync') { + return this.rsyncDirectory( + mediaDir + '/', + this.rsyncDest(credentials, '/media/'), + onProgress, + ['*.meta'], + ); + } + return this.scpUploadDir( + credentials, mediaDir, remotePath, onProgress, + (name) => !name.endsWith(META_EXTENSION), + ); + } + + // ── SCP mode ────────────────────────────────────────────────────────── + + private async scpUploadDir( + credentials: PublishCredentials, + localDir: string, + remoteDir: string, + onProgress: ProgressCallback, + fileFilter?: (name: string) => boolean, + ): Promise { const client = await scpClient({ host: credentials.sshHost, username: credentials.sshUser, @@ -87,109 +137,53 @@ export class PublishEngine extends EventEmitter { }); try { - // Phase 1: html/ → remote root (0–33%) - onProgress(0, 'Uploading HTML files...'); - const htmlResult = await this.scpUploadDirectory( - client, - htmlDir, - credentials.sshRemotePath, - (p, msg) => onProgress(Math.round(p * 0.33), msg), - ); - result.htmlFilesUploaded = htmlResult.uploaded; - result.filesSkipped += htmlResult.skipped; + const files = await this.collectFiles(localDir, '', fileFilter); + let uploaded = 0; + let skipped = 0; - // Phase 2: thumbnails/ → remote/thumbnails/ (33–66%) - onProgress(33, 'Uploading thumbnails...'); - if (await this.directoryExists(thumbnailsDir)) { - const thumbResult = await this.scpUploadDirectory( - client, - thumbnailsDir, - path.posix.join(credentials.sshRemotePath, 'thumbnails'), - (p, msg) => onProgress(33 + Math.round(p * 0.33), msg), - ); - result.thumbnailFilesUploaded = thumbResult.uploaded; - result.filesSkipped += thumbResult.skipped; + if (files.length === 0) { + onProgress(100, 'No files to upload'); + return { filesUploaded: 0, filesSkipped: 0 }; } - // Phase 3: media/ → remote/media/ (66–99%), excluding .meta files - onProgress(66, 'Uploading media files...'); - if (await this.directoryExists(mediaDir)) { - const mediaResult = await this.scpUploadDirectory( - client, - mediaDir, - path.posix.join(credentials.sshRemotePath, 'media'), - (p, msg) => onProgress(66 + Math.round(p * 0.33), msg), - (name) => !name.endsWith(META_EXTENSION), - ); - result.mediaFilesUploaded = mediaResult.uploaded; - result.filesSkipped += mediaResult.skipped; + await this.scpEnsureDir(client, remoteDir); + const createdDirs = new Set(); + + for (let i = 0; i < files.length; i++) { + const relativePath = files[i]; + const localPath = path.join(localDir, relativePath); + const remotePath = path.posix.join(remoteDir, relativePath.split(path.sep).join('/')); + + // Ensure parent directory exists on remote + const remoteParent = path.posix.dirname(remotePath); + if (!createdDirs.has(remoteParent)) { + await this.scpEnsureDir(client, remoteParent); + createdDirs.add(remoteParent); + } + + // Check if we need to upload (compare mtime) + const localStat = await fs.stat(localPath); + const needsUpload = await this.scpNeedsUpload(client, remotePath, localStat.mtimeMs); + + if (needsUpload) { + await client.uploadFile(localPath, remotePath); + uploaded++; + const progress = Math.round(((i + 1) / files.length) * 100); + onProgress(progress, `Uploaded ${relativePath} (${uploaded}/${files.length})`); + } else { + skipped++; + const progress = Math.round(((i + 1) / files.length) * 100); + onProgress(progress, `Skipped ${relativePath} (${i + 1}/${files.length})`); + } } + + onProgress(100, `Done: ${uploaded} uploaded, ${skipped} unchanged`); + return { filesUploaded: uploaded, filesSkipped: skipped }; } finally { client.close(); } } - /** - * Recursively upload a local directory to a remote path via SCP/SFTP. - * Only uploads files that are newer than the remote version. - */ - private async scpUploadDirectory( - client: ScpClient, - localDir: string, - remoteDir: string, - onProgress: ProgressCallback, - fileFilter?: (name: string) => boolean, - ): Promise<{ uploaded: number; skipped: number }> { - // Collect all files first for progress tracking - const files = await this.collectFiles(localDir, '', fileFilter); - let uploaded = 0; - let skipped = 0; - - if (files.length === 0) { - onProgress(100, 'No files to upload'); - return { uploaded: 0, skipped: 0 }; - } - - // Ensure remote directory exists - await this.scpEnsureDir(client, remoteDir); - - // Track created directories to avoid redundant mkdir calls - const createdDirs = new Set(); - - for (let i = 0; i < files.length; i++) { - const relativePath = files[i]; - const localPath = path.join(localDir, relativePath); - const remotePath = path.posix.join(remoteDir, relativePath.split(path.sep).join('/')); - - // Ensure parent directory exists on remote - const remoteParent = path.posix.dirname(remotePath); - if (!createdDirs.has(remoteParent)) { - await this.scpEnsureDir(client, remoteParent); - createdDirs.add(remoteParent); - } - - // Check if we need to upload (compare mtime) - const localStat = await fs.stat(localPath); - const needsUpload = await this.scpNeedsUpload(client, remotePath, localStat.mtimeMs); - - if (needsUpload) { - await client.uploadFile(localPath, remotePath); - uploaded++; - } else { - skipped++; - } - - const progress = ((i + 1) / files.length) * 100; - onProgress(progress, `${uploaded} uploaded, ${skipped} skipped (${i + 1}/${files.length})`); - } - - return { uploaded, skipped }; - } - - /** - * Check if a local file is newer than the remote file. - * Returns true if upload is needed. - */ private async scpNeedsUpload( client: ScpClient, remotePath: string, @@ -197,11 +191,9 @@ export class PublishEngine extends EventEmitter { ): Promise { try { const remoteStat = await client.stat(remotePath); - // SSH2 Stats.mtime is in seconds, localMtimeMs is in milliseconds const remoteMtimeMs = remoteStat.mtime * 1000; return localMtimeMs > remoteMtimeMs; } catch { - // File doesn't exist on remote → needs upload return true; } } @@ -214,61 +206,20 @@ export class PublishEngine extends EventEmitter { } } - // ── rsync mode ──────────────────────────────────────────────────────────── + // ── rsync mode ──────────────────────────────────────────────────────── - private async uploadViaRsync( - credentials: PublishCredentials, - htmlDir: string, - thumbnailsDir: string, - mediaDir: string, - result: PublishResult, - onProgress: ProgressCallback, - ): Promise { - const remoteDest = `${credentials.sshUser}@${credentials.sshHost}:${credentials.sshRemotePath}`; - - // Phase 1: html/ → remote root (0–33%) - onProgress(0, 'Syncing HTML files via rsync...'); - const htmlCount = await this.rsyncDirectory( - htmlDir + '/', - remoteDest + '/', - ); - result.htmlFilesUploaded = htmlCount; - onProgress(33, 'HTML sync complete'); - - // Phase 2: thumbnails/ → remote/thumbnails/ (33–66%) - if (await this.directoryExists(thumbnailsDir)) { - onProgress(33, 'Syncing thumbnails via rsync...'); - const thumbCount = await this.rsyncDirectory( - thumbnailsDir + '/', - remoteDest + '/thumbnails/', - ); - result.thumbnailFilesUploaded = thumbCount; - } - onProgress(66, 'Thumbnails sync complete'); - - // Phase 3: media/ → remote/media/ (66–99%), excluding .meta files - if (await this.directoryExists(mediaDir)) { - onProgress(66, 'Syncing media files via rsync...'); - const mediaCount = await this.rsyncDirectory( - mediaDir + '/', - remoteDest + '/media/', - ['*.meta'], - ); - result.mediaFilesUploaded = mediaCount; - } - onProgress(99, 'Media sync complete'); + private rsyncDest(credentials: PublishCredentials, suffix: string): string { + return `${credentials.sshUser}@${credentials.sshHost}:${credentials.sshRemotePath}${suffix}`; } - /** - * Run rsync for a directory with incremental (--update --times) and recursive transfer. - * Returns estimated file count from stdout. - */ private rsyncDirectory( src: string, dest: string, + onProgress: ProgressCallback, exclude?: string[], - ): Promise { + ): Promise { return new Promise((resolve, reject) => { + onProgress(0, 'Starting rsync...'); rsync( { src, @@ -283,20 +234,18 @@ export class PublishEngine extends EventEmitter { if (error) { reject(error); } else { - // Count uploaded files from rsync stdout (each transferred file gets a line) const lines = stdout.trim().split('\n').filter((l: string) => l.length > 0); - resolve(lines.length); + const count = lines.length; + onProgress(100, `rsync complete: ${count} files transferred`); + resolve({ filesUploaded: count, filesSkipped: 0 }); } }, ); }); } - // ── Helpers ─────────────────────────────────────────────────────────────── + // ── Helpers ─────────────────────────────────────────────────────────── - /** - * Recursively collect all file paths relative to baseDir, with optional filter. - */ private async collectFiles( baseDir: string, prefix: string, @@ -329,6 +278,12 @@ export class PublishEngine extends EventEmitter { return files; } + private ensureProjectContext(): void { + if (!this.dataDir || !this.projectId) { + throw new Error('No project context set'); + } + } + private validateCredentials(credentials: PublishCredentials): void { if (!credentials.sshHost?.trim()) { throw new Error('SSH host is required'); diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index e00edd2..a670824 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -112,5 +112,5 @@ export { PublishEngine, getPublishEngine, type PublishCredentials, - type PublishResult, + type DirectoryUploadResult, } from './PublishEngine'; diff --git a/src/main/ipc/publishHandlers.ts b/src/main/ipc/publishHandlers.ts index 843dfd9..d7db765 100644 --- a/src/main/ipc/publishHandlers.ts +++ b/src/main/ipc/publishHandlers.ts @@ -15,16 +15,42 @@ export function registerPublishHandlers(safeHandle: SafeHandle): void { const publishEngine = getPublishEngine(); publishEngine.setProjectContext(project.id, project.dataPath!); - return taskManager.runTask({ - id: `publish-upload-${Date.now()}`, - name: 'Upload Site', - groupId: 'publish', - groupName: 'Site Publishing', - execute: async (onProgress) => { - return publishEngine.uploadSite(credentials, (progress, message) => { - onProgress(progress, message); - }); - }, + const ts = Date.now(); + const groupId = `publish-${ts}`; + const groupName = 'Site Publishing'; + + // Launch three parallel tasks, one per directory + const htmlTask = taskManager.runTask({ + id: `publish-html-${ts}`, + name: 'Upload HTML', + groupId, + groupName, + execute: (onProgress) => publishEngine.uploadHtml(credentials, onProgress), }); + + const thumbsTask = taskManager.runTask({ + id: `publish-thumbnails-${ts}`, + name: 'Upload Thumbnails', + groupId, + groupName, + execute: (onProgress) => publishEngine.uploadThumbnails(credentials, onProgress), + }); + + const mediaTask = taskManager.runTask({ + id: `publish-media-${ts}`, + name: 'Upload Media', + groupId, + groupName, + execute: (onProgress) => publishEngine.uploadMedia(credentials, onProgress), + }); + + const [html, thumbnails, media] = await Promise.all([htmlTask, thumbsTask, mediaTask]); + + return { + htmlFilesUploaded: html.filesUploaded, + thumbnailFilesUploaded: thumbnails.filesUploaded, + mediaFilesUploaded: media.filesUploaded, + filesSkipped: html.filesSkipped + thumbnails.filesSkipped + media.filesSkipped, + }; }); } diff --git a/tests/engine/PublishEngine.test.ts b/tests/engine/PublishEngine.test.ts index 1f588d4..dae292d 100644 --- a/tests/engine/PublishEngine.test.ts +++ b/tests/engine/PublishEngine.test.ts @@ -2,12 +2,13 @@ * PublishEngine Unit Tests * * Tests the site upload engine that publishes generated site content - * via SCP or rsync to a remote server. + * via SCP or rsync to a remote server. Each directory (html, thumbnails, + * media) is uploaded as an independent operation with per-file progress. */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import path from 'path'; -import { PublishEngine, type PublishCredentials, type PublishResult } from '../../src/main/engine/PublishEngine'; +import { PublishEngine, type PublishCredentials, type DirectoryUploadResult } from '../../src/main/engine/PublishEngine'; // Hoist mock variables so they're available inside vi.mock factories const { @@ -108,7 +109,7 @@ describe('PublishEngine', () => { it('should throw if no project context is set', async () => { const noContextEngine = new PublishEngine(); await expect( - noContextEngine.uploadSite(defaultCredentials, vi.fn()), + noContextEngine.uploadHtml(defaultCredentials, vi.fn()), ).rejects.toThrow('No project context'); }); }); @@ -116,19 +117,19 @@ describe('PublishEngine', () => { describe('credential validation', () => { it('should throw if sshHost is empty', async () => { await expect( - engine.uploadSite({ ...defaultCredentials, sshHost: '' }, vi.fn()), + engine.uploadHtml({ ...defaultCredentials, sshHost: '' }, vi.fn()), ).rejects.toThrow('SSH host is required'); }); it('should throw if sshUser is empty', async () => { await expect( - engine.uploadSite({ ...defaultCredentials, sshUser: '' }, vi.fn()), + engine.uploadHtml({ ...defaultCredentials, sshUser: '' }, vi.fn()), ).rejects.toThrow('SSH user is required'); }); it('should throw if sshRemotePath is empty', async () => { await expect( - engine.uploadSite({ ...defaultCredentials, sshRemotePath: '' }, vi.fn()), + engine.uploadHtml({ ...defaultCredentials, sshRemotePath: '' }, vi.fn()), ).rejects.toThrow('Remote path is required'); }); }); @@ -140,14 +141,15 @@ describe('PublishEngine', () => { }); await expect( - engine.uploadSite(defaultCredentials, vi.fn()), + engine.uploadHtml(defaultCredentials, vi.fn()), ).rejects.toThrow('Generated site not found'); }); }); - describe('SCP mode upload', () => { + // ── SCP mode: uploadHtml ────────────────────────────────────────────── + + describe('SCP mode – uploadHtml', () => { it('should upload html files to remote root', async () => { - // html/ contains index.html mockReaddir.mockImplementation(async (dir: string, opts?: any) => { if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { return [{ name: 'index.html', isDirectory: () => false, isFile: () => true }]; @@ -155,49 +157,137 @@ describe('PublishEngine', () => { return []; }); - mockFsStat.mockResolvedValue({ - isDirectory: () => false, - isFile: () => true, - mtimeMs: Date.now(), - }); - const onProgress = vi.fn(); - const result = await engine.uploadSite(defaultCredentials, onProgress); + const result = await engine.uploadHtml(defaultCredentials, onProgress); - expect(result.htmlFilesUploaded).toBeGreaterThanOrEqual(0); + expect(result.filesUploaded).toBe(1); + expect(mockUploadFile).toHaveBeenCalledTimes(1); expect(onProgress).toHaveBeenCalled(); }); - it('should upload thumbnail files to remote thumbnails/', async () => { + it('should recurse into subdirectories', async () => { mockReaddir.mockImplementation(async (dir: string, opts?: any) => { if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { - return []; + return [{ name: '2026', isDirectory: () => true, isFile: () => false }]; } + if (dir === path.join(dataDir, 'html', '2026') && opts?.withFileTypes) { + return [{ name: 'post.html', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + const result = await engine.uploadHtml(defaultCredentials, vi.fn()); + + expect(mockMkdir).toHaveBeenCalled(); + expect(result.filesUploaded).toBe(1); + }); + + it('should skip files that are not newer than remote', async () => { + const remoteTime = Date.now() / 1000; + const localTimeOlder = (remoteTime - 100) * 1000; + + mockReaddir.mockImplementation(async (dir: string, opts?: any) => { + if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { + return [{ name: 'old.html', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + mockFsStat.mockResolvedValue({ isDirectory: () => false, isFile: () => true, mtimeMs: localTimeOlder }); + mockStat.mockResolvedValue({ mtime: remoteTime }); + + const result = await engine.uploadHtml(defaultCredentials, vi.fn()); + + expect(mockUploadFile).not.toHaveBeenCalled(); + expect(result.filesSkipped).toBe(1); + }); + + it('should upload files that are newer than remote', async () => { + const remoteTime = Date.now() / 1000 - 100; + const localTimeNewer = Date.now(); + + mockReaddir.mockImplementation(async (dir: string, opts?: any) => { + if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { + return [{ name: 'new.html', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + mockFsStat.mockResolvedValue({ isDirectory: () => false, isFile: () => true, mtimeMs: localTimeNewer }); + mockStat.mockResolvedValue({ mtime: remoteTime }); + + const result = await engine.uploadHtml(defaultCredentials, vi.fn()); + + expect(mockUploadFile).toHaveBeenCalled(); + expect(result.filesUploaded).toBe(1); + }); + + it('should report per-file progress with filename in message', async () => { + mockReaddir.mockImplementation(async (dir: string, opts?: any) => { + if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { + return [ + { name: 'a.html', isDirectory: () => false, isFile: () => true }, + { name: 'b.html', isDirectory: () => false, isFile: () => true }, + { name: 'c.html', isDirectory: () => false, isFile: () => true }, + ]; + } + return []; + }); + + const onProgress = vi.fn(); + await engine.uploadHtml(defaultCredentials, onProgress); + + // progress reported per file, including final 100 + const progressValues = onProgress.mock.calls.map(([p]: [number]) => p); + expect(progressValues.length).toBeGreaterThanOrEqual(3); + expect(progressValues[progressValues.length - 1]).toBe(100); + // intermediate progress should be between 0 and 100 exclusive + expect(progressValues.some(v => v > 0 && v < 100)).toBe(true); + // should include filenames in messages + const messages = onProgress.mock.calls.map(([, m]: [number, string]) => m); + expect(messages.some(m => m.includes('a.html'))).toBe(true); + }); + + it('should return a DirectoryUploadResult', async () => { + mockReaddir.mockResolvedValue([]); + const result = await engine.uploadHtml(defaultCredentials, vi.fn()); + + expect(result).toHaveProperty('filesUploaded'); + expect(result).toHaveProperty('filesSkipped'); + expect(typeof result.filesUploaded).toBe('number'); + expect(typeof result.filesSkipped).toBe('number'); + }); + }); + + // ── SCP mode: uploadThumbnails ──────────────────────────────────────── + + describe('SCP mode – uploadThumbnails', () => { + it('should upload to remote thumbnails/ subdirectory', async () => { + mockReaddir.mockImplementation(async (dir: string, opts?: any) => { if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) { return [{ name: 'thumb1.jpg', isDirectory: () => false, isFile: () => true }]; } return []; }); - mockFsStat.mockResolvedValue({ - isDirectory: () => false, - isFile: () => true, - mtimeMs: Date.now(), - }); - - const result = await engine.uploadSite(defaultCredentials, vi.fn()); - - expect(result.thumbnailFilesUploaded).toBeGreaterThanOrEqual(0); + const result = await engine.uploadThumbnails(defaultCredentials, vi.fn()); + expect(result.filesUploaded).toBe(1); }); - it('should only upload image files from media, not .meta sidecars', async () => { + it('should return zero counts if thumbnails dir does not exist', async () => { + mockAccess.mockImplementation(async (p: string) => { + if ((p as string).includes('thumbnails')) throw new Error('ENOENT'); + }); + + const result = await engine.uploadThumbnails(defaultCredentials, vi.fn()); + expect(result.filesUploaded).toBe(0); + expect(result.filesSkipped).toBe(0); + }); + }); + + // ── SCP mode: uploadMedia ──────────────────────────────────────────── + + describe('SCP mode – uploadMedia', () => { + it('should upload to remote media/ subdirectory, excluding .meta sidecars', async () => { mockReaddir.mockImplementation(async (dir: string, opts?: any) => { - if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { - return []; - } - if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) { - return []; - } if (dir === path.join(dataDir, 'media') && opts?.withFileTypes) { return [ { name: 'photo.jpg', isDirectory: () => false, isFile: () => true }, @@ -209,171 +299,83 @@ describe('PublishEngine', () => { return []; }); - mockFsStat.mockResolvedValue({ - isDirectory: () => false, - isFile: () => true, - mtimeMs: Date.now(), - }); + const result = await engine.uploadMedia(defaultCredentials, vi.fn()); - const result = await engine.uploadSite(defaultCredentials, vi.fn()); - - // Should upload photo.jpg and document.pdf, but NOT .meta files - expect(result.mediaFilesUploaded).toBeGreaterThanOrEqual(0); + // Only photo.jpg and document.pdf should be uploaded + expect(result.filesUploaded).toBe(2); + expect(mockUploadFile).toHaveBeenCalledTimes(2); }); - it('should skip files that are not newer than remote', async () => { - const remoteTime = Date.now() / 1000; // SSH stats use seconds - const localTimeOlder = (remoteTime - 100) * 1000; // local is older (ms) - - mockReaddir.mockImplementation(async (dir: string, opts?: any) => { - if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { - return [{ name: 'old.html', isDirectory: () => false, isFile: () => true }]; - } - return []; + it('should return zero counts if media dir does not exist', async () => { + mockAccess.mockImplementation(async (p: string) => { + if ((p as string).includes('media')) throw new Error('ENOENT'); }); - mockFsStat.mockResolvedValue({ - isDirectory: () => false, - isFile: () => true, - mtimeMs: localTimeOlder, - }); - - // Remote file exists and is newer - mockStat.mockResolvedValue({ mtime: remoteTime }); - - const result = await engine.uploadSite(defaultCredentials, vi.fn()); - - // File should be skipped since remote is newer - expect(mockUploadFile).not.toHaveBeenCalled(); - expect(result.filesSkipped).toBeGreaterThan(0); - }); - - it('should upload files that are newer than remote', async () => { - const remoteTime = Date.now() / 1000 - 100; // Remote is 100s old - const localTimeNewer = Date.now(); // local is current (ms) - - mockReaddir.mockImplementation(async (dir: string, opts?: any) => { - if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { - return [{ name: 'new.html', isDirectory: () => false, isFile: () => true }]; - } - return []; - }); - - mockFsStat.mockResolvedValue({ - isDirectory: () => false, - isFile: () => true, - mtimeMs: localTimeNewer, - }); - - mockStat.mockResolvedValue({ mtime: remoteTime }); - - const result = await engine.uploadSite(defaultCredentials, vi.fn()); - - expect(mockUploadFile).toHaveBeenCalled(); - expect(result.htmlFilesUploaded).toBeGreaterThan(0); - }); - - it('should recurse into subdirectories', async () => { - mockReaddir.mockImplementation(async (dir: string, opts?: any) => { - if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { - return [ - { name: '2026', isDirectory: () => true, isFile: () => false }, - ]; - } - if (dir === path.join(dataDir, 'html', '2026') && opts?.withFileTypes) { - return [ - { name: 'post.html', isDirectory: () => false, isFile: () => true }, - ]; - } - return []; - }); - - mockFsStat.mockResolvedValue({ - isDirectory: () => false, - isFile: () => true, - mtimeMs: Date.now(), - }); - - const result = await engine.uploadSite(defaultCredentials, vi.fn()); - - expect(mockMkdir).toHaveBeenCalled(); // Should create remote subdir - expect(result.htmlFilesUploaded).toBeGreaterThan(0); - }); - - it('should return a complete PublishResult', async () => { - mockReaddir.mockResolvedValue([]); - - const result = await engine.uploadSite(defaultCredentials, vi.fn()); - - expect(result).toHaveProperty('htmlFilesUploaded'); - expect(result).toHaveProperty('thumbnailFilesUploaded'); - expect(result).toHaveProperty('mediaFilesUploaded'); - expect(result).toHaveProperty('filesSkipped'); - expect(typeof result.htmlFilesUploaded).toBe('number'); - expect(typeof result.thumbnailFilesUploaded).toBe('number'); - expect(typeof result.mediaFilesUploaded).toBe('number'); - expect(typeof result.filesSkipped).toBe('number'); + const result = await engine.uploadMedia(defaultCredentials, vi.fn()); + expect(result.filesUploaded).toBe(0); + expect(result.filesSkipped).toBe(0); }); }); - describe('rsync mode upload', () => { - const rsyncCredentials: PublishCredentials = { - ...defaultCredentials, - sshMode: 'rsync', - }; + // ── rsync mode ──────────────────────────────────────────────────────── + + describe('rsync mode – uploadHtml', () => { + const rsyncCredentials: PublishCredentials = { ...defaultCredentials, sshMode: 'rsync' }; it('should call rsync for html directory', async () => { - const rsync = (await import('rsyncwrapper')).default; - - const result = await engine.uploadSite(rsyncCredentials, vi.fn()); - - expect(rsync).toHaveBeenCalled(); - expect(result.htmlFilesUploaded).toBeGreaterThanOrEqual(0); + await engine.uploadHtml(rsyncCredentials, vi.fn()); + expect(mockRsync).toHaveBeenCalledTimes(1); }); it('should use --update and --times flags for incremental transfer', async () => { - const rsync = (await import('rsyncwrapper')).default; + await engine.uploadHtml(rsyncCredentials, vi.fn()); - await engine.uploadSite(rsyncCredentials, vi.fn()); - - // Check that rsync was called with update semantics - const calls = vi.mocked(rsync).mock.calls; - expect(calls.length).toBeGreaterThan(0); - for (const [options] of calls) { - expect(options.args).toContain('--update'); - expect(options.times).toBe(true); - expect(options.recursive).toBe(true); - } - }); - - it('should exclude .meta files when syncing media', async () => { - const rsync = (await import('rsyncwrapper')).default; - - await engine.uploadSite(rsyncCredentials, vi.fn()); - - const calls = vi.mocked(rsync).mock.calls; - // Find the media sync call (dest contains /media) - const mediaCall = calls.find(([opts]) => - typeof opts.dest === 'string' && opts.dest.includes('/media'), - ); - if (mediaCall) { - expect(mediaCall[0].exclude).toContain('*.meta'); - } + const [options] = mockRsync.mock.calls[0]; + expect(options.args).toContain('--update'); + expect(options.times).toBe(true); + expect(options.recursive).toBe(true); }); }); - describe('progress reporting', () => { - it('should report progress through all three phases', async () => { + describe('rsync mode – uploadMedia', () => { + const rsyncCredentials: PublishCredentials = { ...defaultCredentials, sshMode: 'rsync' }; + + it('should exclude .meta files when syncing media', async () => { + await engine.uploadMedia(rsyncCredentials, vi.fn()); + + const [options] = mockRsync.mock.calls[0]; + expect(options.exclude).toContain('*.meta'); + }); + }); + + // ── per-file progress across methods ────────────────────────────────── + + describe('per-file progress', () => { + it('uploadHtml progress reaches 100 on completion', async () => { mockReaddir.mockResolvedValue([]); - const onProgress = vi.fn(); - await engine.uploadSite(defaultCredentials, onProgress); + await engine.uploadHtml(defaultCredentials, onProgress); - // Should have called onProgress at least once per phase - expect(onProgress).toHaveBeenCalled(); - const progressValues = onProgress.mock.calls.map(([p]: [number]) => p); - // Should reach 100 - expect(progressValues[progressValues.length - 1]).toBe(100); + const last = onProgress.mock.calls[onProgress.mock.calls.length - 1]; + expect(last[0]).toBe(100); + }); + + it('uploadThumbnails progress reaches 100 on completion', async () => { + mockReaddir.mockResolvedValue([]); + const onProgress = vi.fn(); + await engine.uploadThumbnails(defaultCredentials, onProgress); + + const last = onProgress.mock.calls[onProgress.mock.calls.length - 1]; + expect(last[0]).toBe(100); + }); + + it('uploadMedia progress reaches 100 on completion', async () => { + mockReaddir.mockResolvedValue([]); + const onProgress = vi.fn(); + await engine.uploadMedia(defaultCredentials, onProgress); + + const last = onProgress.mock.calls[onProgress.mock.calls.length - 1]; + expect(last[0]).toBe(100); }); }); });