/** * PublishEngine Unit Tests * * Tests the site upload engine that publishes generated site content * 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 DirectoryUploadResult } from '../../src/main/engine/PublishEngine'; // Hoist mock variables so they're available inside vi.mock factories const { mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient, mockReaddir, mockFsStat, mockAccess, mockRsync, } = vi.hoisted(() => { const mockStat = vi.fn(); const mockUploadFile = vi.fn(); const mockMkdir = vi.fn(); const mockClose = vi.fn(); const mockScpClient = { stat: mockStat, uploadFile: mockUploadFile, mkdir: mockMkdir, close: mockClose, }; const mockReaddir = vi.fn(); const mockFsStat = vi.fn(); const mockAccess = vi.fn(); const mockRsync = vi.fn((_options: any, callback: any) => { callback(null, '', '', 'rsync command'); }); return { mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient, mockReaddir, mockFsStat, mockAccess, mockRsync, }; }); // Mock node-scp vi.mock('node-scp', () => ({ Client: vi.fn().mockResolvedValue(mockScpClient), default: vi.fn().mockResolvedValue(mockScpClient), })); // Mock rsyncwrapper vi.mock('rsyncwrapper', () => ({ default: mockRsync, })); // Mock fs/promises vi.mock('fs/promises', () => ({ default: { readdir: (...args: any[]) => mockReaddir(...args), stat: (...args: any[]) => mockFsStat(...args), access: (...args: any[]) => mockAccess(...args), }, readdir: (...args: any[]) => mockReaddir(...args), stat: (...args: any[]) => mockFsStat(...args), access: (...args: any[]) => mockAccess(...args), })); // Mock fs vi.mock('fs', () => ({ default: { constants: { F_OK: 0 } }, constants: { F_OK: 0 }, })); describe('PublishEngine', () => { let engine: PublishEngine; const dataDir = '/projects/test-project'; const defaultCredentials: PublishCredentials = { sshHost: 'example.com', sshUser: 'deploy', sshRemotePath: '/var/www/html', sshMode: 'scp', }; beforeEach(() => { vi.clearAllMocks(); engine = new PublishEngine(); engine.setProjectContext('test-project', dataDir); // Default: directories exist, files are regular files mockAccess.mockResolvedValue(undefined); mockFsStat.mockResolvedValue({ isDirectory: () => false, isFile: () => true, mtimeMs: Date.now() }); mockReaddir.mockResolvedValue([]); mockStat.mockRejectedValue(new Error('No such file')); // Remote file doesn't exist → upload mockUploadFile.mockResolvedValue(undefined); mockMkdir.mockResolvedValue(undefined); mockClose.mockResolvedValue(undefined); }); afterEach(() => { vi.restoreAllMocks(); }); describe('constructor and project context', () => { it('should be instantiated via getPublishEngine singleton', async () => { const { getPublishEngine } = await import('../../src/main/engine/PublishEngine'); const e1 = getPublishEngine(); const e2 = getPublishEngine(); expect(e1).toBe(e2); }); it('should throw if no project context is set', async () => { const noContextEngine = new PublishEngine(); await expect( noContextEngine.uploadHtml(defaultCredentials, vi.fn()), ).rejects.toThrow('No project context'); }); }); describe('credential validation', () => { it('should throw if sshHost is empty', async () => { await expect( engine.uploadHtml({ ...defaultCredentials, sshHost: '' }, vi.fn()), ).rejects.toThrow('SSH host is required'); }); it('should throw if sshUser is empty', async () => { await expect( engine.uploadHtml({ ...defaultCredentials, sshUser: '' }, vi.fn()), ).rejects.toThrow('SSH user is required'); }); it('should throw if sshRemotePath is empty', async () => { await expect( engine.uploadHtml({ ...defaultCredentials, sshRemotePath: '' }, vi.fn()), ).rejects.toThrow('Remote path is required'); }); }); describe('directory validation', () => { it('should throw if html directory does not exist', async () => { mockAccess.mockImplementation(async (p: string) => { if ((p as string).endsWith('html')) throw new Error('ENOENT'); }); await expect( engine.uploadHtml(defaultCredentials, vi.fn()), ).rejects.toThrow('Generated site not found'); }); }); // ── SCP mode: uploadHtml ────────────────────────────────────────────── describe('SCP mode – uploadHtml', () => { it('should upload html files to remote root', async () => { mockReaddir.mockImplementation(async (dir: string, opts?: any) => { if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) { return [{ name: 'index.html', isDirectory: () => false, isFile: () => true }]; } return []; }); const onProgress = vi.fn(); const result = await engine.uploadHtml(defaultCredentials, onProgress); expect(result.filesUploaded).toBe(1); expect(mockUploadFile).toHaveBeenCalledTimes(1); expect(onProgress).toHaveBeenCalled(); }); 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 []; }); 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 []; }); const result = await engine.uploadThumbnails(defaultCredentials, vi.fn()); expect(result.filesUploaded).toBe(1); }); 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, 'media') && opts?.withFileTypes) { return [ { name: 'photo.jpg', isDirectory: () => false, isFile: () => true }, { name: 'photo.jpg.meta', isDirectory: () => false, isFile: () => true }, { name: 'document.pdf', isDirectory: () => false, isFile: () => true }, { name: 'document.pdf.meta', isDirectory: () => false, isFile: () => true }, ]; } return []; }); const result = await engine.uploadMedia(defaultCredentials, vi.fn()); // Only photo.jpg and document.pdf should be uploaded expect(result.filesUploaded).toBe(2); expect(mockUploadFile).toHaveBeenCalledTimes(2); }); 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'); }); const result = await engine.uploadMedia(defaultCredentials, vi.fn()); expect(result.filesUploaded).toBe(0); expect(result.filesSkipped).toBe(0); }); }); // ── rsync mode ──────────────────────────────────────────────────────── describe('rsync mode – uploadHtml', () => { const rsyncCredentials: PublishCredentials = { ...defaultCredentials, sshMode: 'rsync' }; it('should call rsync for html directory', async () => { await engine.uploadHtml(rsyncCredentials, vi.fn()); expect(mockRsync).toHaveBeenCalledTimes(1); }); it('should use --update and --times flags for incremental transfer', async () => { await engine.uploadHtml(rsyncCredentials, vi.fn()); const [options] = mockRsync.mock.calls[0]; expect(options.args).toContain('--update'); expect(options.times).toBe(true); expect(options.recursive).toBe(true); }); }); 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'); }); }); // ── 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', () => { it('uploadHtml progress reaches 100 on completion', async () => { mockReaddir.mockResolvedValue([]); const onProgress = vi.fn(); await engine.uploadHtml(defaultCredentials, onProgress); 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); }); }); });