BMAD-METHOD/test/unit/file-ops/sync-directory.test.js

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);
});
});
});
});