317 lines
13 KiB
JavaScript
317 lines
13 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { FileOps } from '../../../tools/cli/lib/file-ops.js';
|
|
import { createTempDir, cleanupTempDir, createTestFile } from '../../helpers/temp-dir.js';
|
|
import fs from 'fs-extra';
|
|
import path from 'node:path';
|
|
|
|
describe('FileOps', () => {
|
|
describe('syncDirectory()', () => {
|
|
const fileOps = new FileOps();
|
|
let tmpDir;
|
|
let sourceDir;
|
|
let destDir;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await createTempDir();
|
|
sourceDir = path.join(tmpDir, 'source');
|
|
destDir = path.join(tmpDir, 'dest');
|
|
await fs.ensureDir(sourceDir);
|
|
await fs.ensureDir(destDir);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await cleanupTempDir(tmpDir);
|
|
});
|
|
|
|
describe('hash-based selective update', () => {
|
|
it('should update file when hashes are identical (safe update)', async () => {
|
|
const content = 'identical content';
|
|
await createTestFile(sourceDir, 'file.txt', content);
|
|
await createTestFile(destDir, 'file.txt', content);
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
// File should be updated (copied over) since hashes match
|
|
const destContent = await fs.readFile(path.join(destDir, 'file.txt'), 'utf8');
|
|
expect(destContent).toBe(content);
|
|
});
|
|
|
|
it('should preserve modified file when dest is newer', async () => {
|
|
await createTestFile(sourceDir, 'file.txt', 'source content');
|
|
await createTestFile(destDir, 'file.txt', 'modified by user');
|
|
|
|
// Make dest file newer
|
|
const destFile = path.join(destDir, 'file.txt');
|
|
const futureTime = new Date(Date.now() + 10_000);
|
|
await fs.utimes(destFile, futureTime, futureTime);
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
// User modification should be preserved
|
|
const destContent = await fs.readFile(destFile, 'utf8');
|
|
expect(destContent).toBe('modified by user');
|
|
});
|
|
|
|
it('should update file when source is newer than modified dest', async () => {
|
|
// Create both files first
|
|
await createTestFile(sourceDir, 'file.txt', 'new source content');
|
|
await createTestFile(destDir, 'file.txt', 'old modified content');
|
|
|
|
// Make dest older and source newer with explicit times
|
|
const destFile = path.join(destDir, 'file.txt');
|
|
const sourceFile = path.join(sourceDir, 'file.txt');
|
|
|
|
const pastTime = new Date(Date.now() - 10_000);
|
|
const futureTime = new Date(Date.now() + 10_000);
|
|
|
|
await fs.utimes(destFile, pastTime, pastTime);
|
|
await fs.utimes(sourceFile, futureTime, futureTime);
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
// Should update to source content since source is newer
|
|
const destContent = await fs.readFile(destFile, 'utf8');
|
|
expect(destContent).toBe('new source content');
|
|
});
|
|
});
|
|
|
|
describe('new file handling', () => {
|
|
it('should copy new files from source', async () => {
|
|
await createTestFile(sourceDir, 'new-file.txt', 'new content');
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
expect(await fs.pathExists(path.join(destDir, 'new-file.txt'))).toBe(true);
|
|
expect(await fs.readFile(path.join(destDir, 'new-file.txt'), 'utf8')).toBe('new content');
|
|
});
|
|
|
|
it('should copy multiple new files', async () => {
|
|
await createTestFile(sourceDir, 'file1.txt', 'content1');
|
|
await createTestFile(sourceDir, 'file2.md', 'content2');
|
|
await createTestFile(sourceDir, 'file3.json', 'content3');
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
expect(await fs.pathExists(path.join(destDir, 'file1.txt'))).toBe(true);
|
|
expect(await fs.pathExists(path.join(destDir, 'file2.md'))).toBe(true);
|
|
expect(await fs.pathExists(path.join(destDir, 'file3.json'))).toBe(true);
|
|
});
|
|
|
|
it('should create nested directories for new files', async () => {
|
|
await createTestFile(sourceDir, 'level1/level2/deep.txt', 'deep content');
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
expect(await fs.pathExists(path.join(destDir, 'level1', 'level2', 'deep.txt'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('orphaned file removal', () => {
|
|
it('should remove files that no longer exist in source', async () => {
|
|
await createTestFile(sourceDir, 'keep.txt', 'keep this');
|
|
await createTestFile(destDir, 'keep.txt', 'keep this');
|
|
await createTestFile(destDir, 'remove.txt', 'delete this');
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
expect(await fs.pathExists(path.join(destDir, 'keep.txt'))).toBe(true);
|
|
expect(await fs.pathExists(path.join(destDir, 'remove.txt'))).toBe(false);
|
|
});
|
|
|
|
it('should remove multiple orphaned files', async () => {
|
|
await createTestFile(sourceDir, 'current.txt', 'current');
|
|
await createTestFile(destDir, 'current.txt', 'current');
|
|
await createTestFile(destDir, 'old1.txt', 'orphan 1');
|
|
await createTestFile(destDir, 'old2.txt', 'orphan 2');
|
|
await createTestFile(destDir, 'old3.txt', 'orphan 3');
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
expect(await fs.pathExists(path.join(destDir, 'current.txt'))).toBe(true);
|
|
expect(await fs.pathExists(path.join(destDir, 'old1.txt'))).toBe(false);
|
|
expect(await fs.pathExists(path.join(destDir, 'old2.txt'))).toBe(false);
|
|
expect(await fs.pathExists(path.join(destDir, 'old3.txt'))).toBe(false);
|
|
});
|
|
|
|
it('should remove orphaned directories', async () => {
|
|
await createTestFile(sourceDir, 'keep/file.txt', 'keep');
|
|
await createTestFile(destDir, 'keep/file.txt', 'keep');
|
|
await createTestFile(destDir, 'remove/orphan.txt', 'orphan');
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
expect(await fs.pathExists(path.join(destDir, 'keep'))).toBe(true);
|
|
expect(await fs.pathExists(path.join(destDir, 'remove', 'orphan.txt'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('complex scenarios', () => {
|
|
it('should handle mixed operations in single sync', async () => {
|
|
const now = Date.now();
|
|
const pastTime = now - 100_000; // 100 seconds ago
|
|
const futureTime = now + 100_000; // 100 seconds from now
|
|
|
|
// Identical file (update)
|
|
await createTestFile(sourceDir, 'identical.txt', 'same');
|
|
await createTestFile(destDir, 'identical.txt', 'same');
|
|
|
|
// Modified file with newer dest (preserve)
|
|
await createTestFile(sourceDir, 'modified.txt', 'original');
|
|
await createTestFile(destDir, 'modified.txt', 'user modified');
|
|
const modifiedFile = path.join(destDir, 'modified.txt');
|
|
await fs.utimes(modifiedFile, futureTime, futureTime);
|
|
|
|
// New file (copy)
|
|
await createTestFile(sourceDir, 'new.txt', 'new content');
|
|
|
|
// Orphaned file (remove)
|
|
await createTestFile(destDir, 'orphan.txt', 'delete me');
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
// Verify operations
|
|
expect(await fs.pathExists(path.join(destDir, 'identical.txt'))).toBe(true);
|
|
|
|
expect(await fs.readFile(modifiedFile, 'utf8')).toBe('user modified');
|
|
|
|
expect(await fs.pathExists(path.join(destDir, 'new.txt'))).toBe(true);
|
|
|
|
expect(await fs.pathExists(path.join(destDir, 'orphan.txt'))).toBe(false);
|
|
});
|
|
|
|
it('should handle nested directory changes', async () => {
|
|
// Create nested structure in source
|
|
await createTestFile(sourceDir, 'level1/keep.txt', 'keep');
|
|
await createTestFile(sourceDir, 'level1/level2/deep.txt', 'deep');
|
|
|
|
// Create different nested structure in dest
|
|
await createTestFile(destDir, 'level1/keep.txt', 'keep');
|
|
await createTestFile(destDir, 'level1/remove.txt', 'orphan');
|
|
await createTestFile(destDir, 'old-level/file.txt', 'old');
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
expect(await fs.pathExists(path.join(destDir, 'level1', 'keep.txt'))).toBe(true);
|
|
expect(await fs.pathExists(path.join(destDir, 'level1', 'level2', 'deep.txt'))).toBe(true);
|
|
expect(await fs.pathExists(path.join(destDir, 'level1', 'remove.txt'))).toBe(false);
|
|
expect(await fs.pathExists(path.join(destDir, 'old-level', 'file.txt'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle empty source directory', async () => {
|
|
await createTestFile(destDir, 'file.txt', 'content');
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
// All files should be removed
|
|
expect(await fs.pathExists(path.join(destDir, 'file.txt'))).toBe(false);
|
|
});
|
|
|
|
it('should handle empty destination directory', async () => {
|
|
await createTestFile(sourceDir, 'file.txt', 'content');
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
expect(await fs.pathExists(path.join(destDir, 'file.txt'))).toBe(true);
|
|
});
|
|
|
|
it('should handle Unicode filenames', async () => {
|
|
await createTestFile(sourceDir, '测试.txt', 'chinese');
|
|
await createTestFile(destDir, '测试.txt', 'modified chinese');
|
|
|
|
// Make dest newer
|
|
await fs.utimes(path.join(destDir, '测试.txt'), Date.now() + 10_000, Date.now() + 10_000);
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
// Should preserve user modification
|
|
expect(await fs.readFile(path.join(destDir, '测试.txt'), 'utf8')).toBe('modified chinese');
|
|
});
|
|
|
|
it('should handle large number of files', async () => {
|
|
// Create 50 files in source
|
|
for (let i = 0; i < 50; i++) {
|
|
await createTestFile(sourceDir, `file${i}.txt`, `content ${i}`);
|
|
}
|
|
|
|
// Create 25 matching files and 25 orphaned files in dest
|
|
for (let i = 0; i < 25; i++) {
|
|
await createTestFile(destDir, `file${i}.txt`, `content ${i}`);
|
|
await createTestFile(destDir, `orphan${i}.txt`, `orphan ${i}`);
|
|
}
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
// All 50 source files should exist
|
|
for (let i = 0; i < 50; i++) {
|
|
expect(await fs.pathExists(path.join(destDir, `file${i}.txt`))).toBe(true);
|
|
}
|
|
|
|
// All 25 orphaned files should be removed
|
|
for (let i = 0; i < 25; i++) {
|
|
expect(await fs.pathExists(path.join(destDir, `orphan${i}.txt`))).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('should handle binary files correctly', async () => {
|
|
const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
await fs.writeFile(path.join(sourceDir, 'binary.dat'), buffer);
|
|
await fs.writeFile(path.join(destDir, 'binary.dat'), buffer);
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
const destBuffer = await fs.readFile(path.join(destDir, 'binary.dat'));
|
|
expect(destBuffer).toEqual(buffer);
|
|
});
|
|
});
|
|
|
|
describe('timestamp precision', () => {
|
|
it('should handle files with very close modification times', async () => {
|
|
await createTestFile(sourceDir, 'file.txt', 'source');
|
|
await createTestFile(destDir, 'file.txt', 'dest modified');
|
|
|
|
// Make dest just slightly newer (100ms)
|
|
const destFile = path.join(destDir, 'file.txt');
|
|
await fs.utimes(destFile, Date.now() + 100, Date.now() + 100);
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
// Should preserve user modification even with small time difference
|
|
expect(await fs.readFile(destFile, 'utf8')).toBe('dest modified');
|
|
});
|
|
});
|
|
|
|
describe('data integrity', () => {
|
|
it('should not corrupt files during sync', async () => {
|
|
const content = 'Important data\nLine 2\nLine 3\n';
|
|
await createTestFile(sourceDir, 'data.txt', content);
|
|
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
expect(await fs.readFile(path.join(destDir, 'data.txt'), 'utf8')).toBe(content);
|
|
});
|
|
|
|
it('should handle sync interruption gracefully', async () => {
|
|
// This test verifies that partial syncs don't leave inconsistent state
|
|
await createTestFile(sourceDir, 'file1.txt', 'content1');
|
|
await createTestFile(sourceDir, 'file2.txt', 'content2');
|
|
|
|
// First sync
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
// Modify source
|
|
await createTestFile(sourceDir, 'file3.txt', 'content3');
|
|
|
|
// Second sync
|
|
await fileOps.syncDirectory(sourceDir, destDir);
|
|
|
|
// All files should be present and correct
|
|
expect(await fs.pathExists(path.join(destDir, 'file1.txt'))).toBe(true);
|
|
expect(await fs.pathExists(path.join(destDir, 'file2.txt'))).toBe(true);
|
|
expect(await fs.pathExists(path.join(destDir, 'file3.txt'))).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
});
|