fix: multiple tasks
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user