fix: multiple tasks
This commit is contained in:
@@ -12,10 +12,8 @@ export interface PublishCredentials {
|
|||||||
sshMode: 'scp' | 'rsync';
|
sshMode: 'scp' | 'rsync';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublishResult {
|
export interface DirectoryUploadResult {
|
||||||
htmlFilesUploaded: number;
|
filesUploaded: number;
|
||||||
thumbnailFilesUploaded: number;
|
|
||||||
mediaFilesUploaded: number;
|
|
||||||
filesSkipped: number;
|
filesSkipped: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,49 +35,101 @@ export class PublishEngine extends EventEmitter {
|
|||||||
this.dataDir = dataDir;
|
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,
|
credentials: PublishCredentials,
|
||||||
onProgress: ProgressCallback,
|
onProgress: ProgressCallback,
|
||||||
): Promise<PublishResult> {
|
): Promise<DirectoryUploadResult> {
|
||||||
if (!this.dataDir || !this.projectId) {
|
this.ensureProjectContext();
|
||||||
throw new Error('No project context set');
|
|
||||||
}
|
|
||||||
this.validateCredentials(credentials);
|
this.validateCredentials(credentials);
|
||||||
|
|
||||||
const htmlDir = path.join(this.dataDir, 'html');
|
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
|
|
||||||
await this.ensureDirectoryExists(htmlDir, 'Generated site not found. Please render the site first.');
|
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') {
|
if (credentials.sshMode === 'rsync') {
|
||||||
await this.uploadViaRsync(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress);
|
return this.rsyncDirectory(
|
||||||
} else {
|
htmlDir + '/',
|
||||||
await this.uploadViaScp(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress);
|
this.rsyncDest(credentials, '/'),
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return this.scpUploadDir(credentials, htmlDir, credentials.sshRemotePath, onProgress);
|
||||||
onProgress(100, 'Upload complete');
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SCP mode ──────────────────────────────────────────────────────────────
|
/**
|
||||||
|
* Upload thumbnails/ → remote/thumbnails/.
|
||||||
private async uploadViaScp(
|
* Silently returns zero counts if thumbnails/ does not exist.
|
||||||
|
*/
|
||||||
|
async uploadThumbnails(
|
||||||
credentials: PublishCredentials,
|
credentials: PublishCredentials,
|
||||||
htmlDir: string,
|
|
||||||
thumbnailsDir: string,
|
|
||||||
mediaDir: string,
|
|
||||||
result: PublishResult,
|
|
||||||
onProgress: ProgressCallback,
|
onProgress: ProgressCallback,
|
||||||
): Promise<void> {
|
): Promise<DirectoryUploadResult> {
|
||||||
|
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<DirectoryUploadResult> {
|
||||||
|
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<DirectoryUploadResult> {
|
||||||
const client = await scpClient({
|
const client = await scpClient({
|
||||||
host: credentials.sshHost,
|
host: credentials.sshHost,
|
||||||
username: credentials.sshUser,
|
username: credentials.sshUser,
|
||||||
@@ -87,109 +137,53 @@ export class PublishEngine extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Phase 1: html/ → remote root (0–33%)
|
const files = await this.collectFiles(localDir, '', fileFilter);
|
||||||
onProgress(0, 'Uploading HTML files...');
|
let uploaded = 0;
|
||||||
const htmlResult = await this.scpUploadDirectory(
|
let skipped = 0;
|
||||||
client,
|
|
||||||
htmlDir,
|
|
||||||
credentials.sshRemotePath,
|
|
||||||
(p, msg) => onProgress(Math.round(p * 0.33), msg),
|
|
||||||
);
|
|
||||||
result.htmlFilesUploaded = htmlResult.uploaded;
|
|
||||||
result.filesSkipped += htmlResult.skipped;
|
|
||||||
|
|
||||||
// Phase 2: thumbnails/ → remote/thumbnails/ (33–66%)
|
if (files.length === 0) {
|
||||||
onProgress(33, 'Uploading thumbnails...');
|
onProgress(100, 'No files to upload');
|
||||||
if (await this.directoryExists(thumbnailsDir)) {
|
return { filesUploaded: 0, filesSkipped: 0 };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: media/ → remote/media/ (66–99%), excluding .meta files
|
await this.scpEnsureDir(client, remoteDir);
|
||||||
onProgress(66, 'Uploading media files...');
|
const createdDirs = new Set<string>();
|
||||||
if (await this.directoryExists(mediaDir)) {
|
|
||||||
const mediaResult = await this.scpUploadDirectory(
|
for (let i = 0; i < files.length; i++) {
|
||||||
client,
|
const relativePath = files[i];
|
||||||
mediaDir,
|
const localPath = path.join(localDir, relativePath);
|
||||||
path.posix.join(credentials.sshRemotePath, 'media'),
|
const remotePath = path.posix.join(remoteDir, relativePath.split(path.sep).join('/'));
|
||||||
(p, msg) => onProgress(66 + Math.round(p * 0.33), msg),
|
|
||||||
(name) => !name.endsWith(META_EXTENSION),
|
// Ensure parent directory exists on remote
|
||||||
);
|
const remoteParent = path.posix.dirname(remotePath);
|
||||||
result.mediaFilesUploaded = mediaResult.uploaded;
|
if (!createdDirs.has(remoteParent)) {
|
||||||
result.filesSkipped += mediaResult.skipped;
|
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 {
|
} finally {
|
||||||
client.close();
|
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<string>();
|
|
||||||
|
|
||||||
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(
|
private async scpNeedsUpload(
|
||||||
client: ScpClient,
|
client: ScpClient,
|
||||||
remotePath: string,
|
remotePath: string,
|
||||||
@@ -197,11 +191,9 @@ export class PublishEngine extends EventEmitter {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const remoteStat = await client.stat(remotePath);
|
const remoteStat = await client.stat(remotePath);
|
||||||
// SSH2 Stats.mtime is in seconds, localMtimeMs is in milliseconds
|
|
||||||
const remoteMtimeMs = remoteStat.mtime * 1000;
|
const remoteMtimeMs = remoteStat.mtime * 1000;
|
||||||
return localMtimeMs > remoteMtimeMs;
|
return localMtimeMs > remoteMtimeMs;
|
||||||
} catch {
|
} catch {
|
||||||
// File doesn't exist on remote → needs upload
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,61 +206,20 @@ export class PublishEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── rsync mode ────────────────────────────────────────────────────────────
|
// ── rsync mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async uploadViaRsync(
|
private rsyncDest(credentials: PublishCredentials, suffix: string): string {
|
||||||
credentials: PublishCredentials,
|
return `${credentials.sshUser}@${credentials.sshHost}:${credentials.sshRemotePath}${suffix}`;
|
||||||
htmlDir: string,
|
|
||||||
thumbnailsDir: string,
|
|
||||||
mediaDir: string,
|
|
||||||
result: PublishResult,
|
|
||||||
onProgress: ProgressCallback,
|
|
||||||
): Promise<void> {
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Run rsync for a directory with incremental (--update --times) and recursive transfer.
|
|
||||||
* Returns estimated file count from stdout.
|
|
||||||
*/
|
|
||||||
private rsyncDirectory(
|
private rsyncDirectory(
|
||||||
src: string,
|
src: string,
|
||||||
dest: string,
|
dest: string,
|
||||||
|
onProgress: ProgressCallback,
|
||||||
exclude?: string[],
|
exclude?: string[],
|
||||||
): Promise<number> {
|
): Promise<DirectoryUploadResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
onProgress(0, 'Starting rsync...');
|
||||||
rsync(
|
rsync(
|
||||||
{
|
{
|
||||||
src,
|
src,
|
||||||
@@ -283,20 +234,18 @@ export class PublishEngine extends EventEmitter {
|
|||||||
if (error) {
|
if (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
} else {
|
} 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);
|
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(
|
private async collectFiles(
|
||||||
baseDir: string,
|
baseDir: string,
|
||||||
prefix: string,
|
prefix: string,
|
||||||
@@ -329,6 +278,12 @@ export class PublishEngine extends EventEmitter {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureProjectContext(): void {
|
||||||
|
if (!this.dataDir || !this.projectId) {
|
||||||
|
throw new Error('No project context set');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private validateCredentials(credentials: PublishCredentials): void {
|
private validateCredentials(credentials: PublishCredentials): void {
|
||||||
if (!credentials.sshHost?.trim()) {
|
if (!credentials.sshHost?.trim()) {
|
||||||
throw new Error('SSH host is required');
|
throw new Error('SSH host is required');
|
||||||
|
|||||||
@@ -112,5 +112,5 @@ export {
|
|||||||
PublishEngine,
|
PublishEngine,
|
||||||
getPublishEngine,
|
getPublishEngine,
|
||||||
type PublishCredentials,
|
type PublishCredentials,
|
||||||
type PublishResult,
|
type DirectoryUploadResult,
|
||||||
} from './PublishEngine';
|
} from './PublishEngine';
|
||||||
|
|||||||
@@ -15,16 +15,42 @@ export function registerPublishHandlers(safeHandle: SafeHandle): void {
|
|||||||
const publishEngine = getPublishEngine();
|
const publishEngine = getPublishEngine();
|
||||||
publishEngine.setProjectContext(project.id, project.dataPath!);
|
publishEngine.setProjectContext(project.id, project.dataPath!);
|
||||||
|
|
||||||
return taskManager.runTask({
|
const ts = Date.now();
|
||||||
id: `publish-upload-${Date.now()}`,
|
const groupId = `publish-${ts}`;
|
||||||
name: 'Upload Site',
|
const groupName = 'Site Publishing';
|
||||||
groupId: 'publish',
|
|
||||||
groupName: 'Site Publishing',
|
// Launch three parallel tasks, one per directory
|
||||||
execute: async (onProgress) => {
|
const htmlTask = taskManager.runTask({
|
||||||
return publishEngine.uploadSite(credentials, (progress, message) => {
|
id: `publish-html-${ts}`,
|
||||||
onProgress(progress, message);
|
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,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
* PublishEngine Unit Tests
|
* PublishEngine Unit Tests
|
||||||
*
|
*
|
||||||
* Tests the site upload engine that publishes generated site content
|
* 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 { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
import path from 'path';
|
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
|
// Hoist mock variables so they're available inside vi.mock factories
|
||||||
const {
|
const {
|
||||||
@@ -108,7 +109,7 @@ describe('PublishEngine', () => {
|
|||||||
it('should throw if no project context is set', async () => {
|
it('should throw if no project context is set', async () => {
|
||||||
const noContextEngine = new PublishEngine();
|
const noContextEngine = new PublishEngine();
|
||||||
await expect(
|
await expect(
|
||||||
noContextEngine.uploadSite(defaultCredentials, vi.fn()),
|
noContextEngine.uploadHtml(defaultCredentials, vi.fn()),
|
||||||
).rejects.toThrow('No project context');
|
).rejects.toThrow('No project context');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -116,19 +117,19 @@ describe('PublishEngine', () => {
|
|||||||
describe('credential validation', () => {
|
describe('credential validation', () => {
|
||||||
it('should throw if sshHost is empty', async () => {
|
it('should throw if sshHost is empty', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
engine.uploadSite({ ...defaultCredentials, sshHost: '' }, vi.fn()),
|
engine.uploadHtml({ ...defaultCredentials, sshHost: '' }, vi.fn()),
|
||||||
).rejects.toThrow('SSH host is required');
|
).rejects.toThrow('SSH host is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw if sshUser is empty', async () => {
|
it('should throw if sshUser is empty', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
engine.uploadSite({ ...defaultCredentials, sshUser: '' }, vi.fn()),
|
engine.uploadHtml({ ...defaultCredentials, sshUser: '' }, vi.fn()),
|
||||||
).rejects.toThrow('SSH user is required');
|
).rejects.toThrow('SSH user is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw if sshRemotePath is empty', async () => {
|
it('should throw if sshRemotePath is empty', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
engine.uploadSite({ ...defaultCredentials, sshRemotePath: '' }, vi.fn()),
|
engine.uploadHtml({ ...defaultCredentials, sshRemotePath: '' }, vi.fn()),
|
||||||
).rejects.toThrow('Remote path is required');
|
).rejects.toThrow('Remote path is required');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -140,14 +141,15 @@ describe('PublishEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
engine.uploadSite(defaultCredentials, vi.fn()),
|
engine.uploadHtml(defaultCredentials, vi.fn()),
|
||||||
).rejects.toThrow('Generated site not found');
|
).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 () => {
|
it('should upload html files to remote root', async () => {
|
||||||
// html/ contains index.html
|
|
||||||
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
||||||
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
||||||
return [{ name: 'index.html', isDirectory: () => false, isFile: () => true }];
|
return [{ name: 'index.html', isDirectory: () => false, isFile: () => true }];
|
||||||
@@ -155,49 +157,137 @@ describe('PublishEngine', () => {
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
mockFsStat.mockResolvedValue({
|
|
||||||
isDirectory: () => false,
|
|
||||||
isFile: () => true,
|
|
||||||
mtimeMs: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onProgress = vi.fn();
|
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();
|
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) => {
|
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
||||||
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
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) {
|
if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) {
|
||||||
return [{ name: 'thumb1.jpg', isDirectory: () => false, isFile: () => true }];
|
return [{ name: 'thumb1.jpg', isDirectory: () => false, isFile: () => true }];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
mockFsStat.mockResolvedValue({
|
const result = await engine.uploadThumbnails(defaultCredentials, vi.fn());
|
||||||
isDirectory: () => false,
|
expect(result.filesUploaded).toBe(1);
|
||||||
isFile: () => true,
|
|
||||||
mtimeMs: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
|
||||||
|
|
||||||
expect(result.thumbnailFilesUploaded).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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) => {
|
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) {
|
if (dir === path.join(dataDir, 'media') && opts?.withFileTypes) {
|
||||||
return [
|
return [
|
||||||
{ name: 'photo.jpg', isDirectory: () => false, isFile: () => true },
|
{ name: 'photo.jpg', isDirectory: () => false, isFile: () => true },
|
||||||
@@ -209,171 +299,83 @@ describe('PublishEngine', () => {
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
mockFsStat.mockResolvedValue({
|
const result = await engine.uploadMedia(defaultCredentials, vi.fn());
|
||||||
isDirectory: () => false,
|
|
||||||
isFile: () => true,
|
|
||||||
mtimeMs: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
// Only photo.jpg and document.pdf should be uploaded
|
||||||
|
expect(result.filesUploaded).toBe(2);
|
||||||
// Should upload photo.jpg and document.pdf, but NOT .meta files
|
expect(mockUploadFile).toHaveBeenCalledTimes(2);
|
||||||
expect(result.mediaFilesUploaded).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip files that are not newer than remote', async () => {
|
it('should return zero counts if media dir does not exist', async () => {
|
||||||
const remoteTime = Date.now() / 1000; // SSH stats use seconds
|
mockAccess.mockImplementation(async (p: string) => {
|
||||||
const localTimeOlder = (remoteTime - 100) * 1000; // local is older (ms)
|
if ((p as string).includes('media')) throw new Error('ENOENT');
|
||||||
|
|
||||||
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({
|
const result = await engine.uploadMedia(defaultCredentials, vi.fn());
|
||||||
isDirectory: () => false,
|
expect(result.filesUploaded).toBe(0);
|
||||||
isFile: () => true,
|
expect(result.filesSkipped).toBe(0);
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rsync mode upload', () => {
|
// ── rsync mode ────────────────────────────────────────────────────────
|
||||||
const rsyncCredentials: PublishCredentials = {
|
|
||||||
...defaultCredentials,
|
describe('rsync mode – uploadHtml', () => {
|
||||||
sshMode: 'rsync',
|
const rsyncCredentials: PublishCredentials = { ...defaultCredentials, sshMode: 'rsync' };
|
||||||
};
|
|
||||||
|
|
||||||
it('should call rsync for html directory', async () => {
|
it('should call rsync for html directory', async () => {
|
||||||
const rsync = (await import('rsyncwrapper')).default;
|
await engine.uploadHtml(rsyncCredentials, vi.fn());
|
||||||
|
expect(mockRsync).toHaveBeenCalledTimes(1);
|
||||||
const result = await engine.uploadSite(rsyncCredentials, vi.fn());
|
|
||||||
|
|
||||||
expect(rsync).toHaveBeenCalled();
|
|
||||||
expect(result.htmlFilesUploaded).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use --update and --times flags for incremental transfer', async () => {
|
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());
|
const [options] = mockRsync.mock.calls[0];
|
||||||
|
expect(options.args).toContain('--update');
|
||||||
// Check that rsync was called with update semantics
|
expect(options.times).toBe(true);
|
||||||
const calls = vi.mocked(rsync).mock.calls;
|
expect(options.recursive).toBe(true);
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('progress reporting', () => {
|
describe('rsync mode – uploadMedia', () => {
|
||||||
it('should report progress through all three phases', async () => {
|
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([]);
|
mockReaddir.mockResolvedValue([]);
|
||||||
|
|
||||||
const onProgress = vi.fn();
|
const onProgress = vi.fn();
|
||||||
await engine.uploadSite(defaultCredentials, onProgress);
|
await engine.uploadHtml(defaultCredentials, onProgress);
|
||||||
|
|
||||||
// Should have called onProgress at least once per phase
|
const last = onProgress.mock.calls[onProgress.mock.calls.length - 1];
|
||||||
expect(onProgress).toHaveBeenCalled();
|
expect(last[0]).toBe(100);
|
||||||
const progressValues = onProgress.mock.calls.map(([p]: [number]) => p);
|
});
|
||||||
// Should reach 100
|
|
||||||
expect(progressValues[progressValues.length - 1]).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