// Tests for file conflict detection via mtime comparison. // Uses bun:test. Tests the mtime-based conflict detection and atomic write logic // from ui-electrobun/src/bun/handlers/files-handlers.ts and FileBrowser.svelte. import { describe, it, expect } from 'bun:test'; // ── Replicated conflict detection logic ────────────────────────────────────── interface FileStat { mtimeMs: number; size: number; error?: string; } /** * Check if the file was modified since we last read it. * Returns true if conflict detected (mtime differs). */ function hasConflict(readMtimeMs: number, currentStat: FileStat): boolean { if (readMtimeMs <= 0) return false; // No baseline — skip check if (currentStat.error) return false; // Can't stat — skip check return currentStat.mtimeMs > readMtimeMs; // Modified since read } /** * Simulate atomic write: write to temp file, then rename. * Returns the operations performed for verification. */ function atomicWriteOps(filePath: string, _content: string): { tmpPath: string; finalPath: string } { const tmpPath = filePath + '.agor-tmp'; return { tmpPath, finalPath: filePath }; } // ── Tests ─────────────────────────────────────────────────────────────────── describe('mtime conflict detection', () => { it('no conflict when mtime matches', () => { const readTime = 1700000000000; const stat: FileStat = { mtimeMs: readTime, size: 100 }; expect(hasConflict(readTime, stat)).toBe(false); }); it('conflict detected when mtime is newer', () => { const readTime = 1700000000000; const stat: FileStat = { mtimeMs: readTime + 5000, size: 120 }; expect(hasConflict(readTime, stat)).toBe(true); }); it('no conflict when readMtimeMs is 0 (first write)', () => { const stat: FileStat = { mtimeMs: 1700000005000, size: 120 }; expect(hasConflict(0, stat)).toBe(false); }); it('no conflict when stat returns error', () => { const stat: FileStat = { mtimeMs: 0, size: 0, error: 'ENOENT: no such file' }; expect(hasConflict(1700000000000, stat)).toBe(false); }); it('no conflict when file is older than read (edge case)', () => { const readTime = 1700000005000; const stat: FileStat = { mtimeMs: 1700000000000, size: 100 }; expect(hasConflict(readTime, stat)).toBe(false); }); it('detects tiny mtime difference (1ms)', () => { const readTime = 1700000000000; const stat: FileStat = { mtimeMs: readTime + 1, size: 100 }; expect(hasConflict(readTime, stat)).toBe(true); }); }); describe('atomic write', () => { it('uses .agor-tmp suffix for temp file', () => { const ops = atomicWriteOps('/home/user/project/main.ts', 'content'); expect(ops.tmpPath).toBe('/home/user/project/main.ts.agor-tmp'); expect(ops.finalPath).toBe('/home/user/project/main.ts'); }); it('temp file path differs from final path', () => { const ops = atomicWriteOps('/test/file.txt', 'data'); expect(ops.tmpPath).not.toBe(ops.finalPath); }); it('handles paths with special characters', () => { const ops = atomicWriteOps('/path/with spaces/file.ts', 'data'); expect(ops.tmpPath).toBe('/path/with spaces/file.ts.agor-tmp'); }); }); describe('conflict workflow', () => { it('full read-modify-check-write cycle — no conflict', () => { // 1. Read file, record mtime const readStat: FileStat = { mtimeMs: 1700000000000, size: 50 }; const readMtimeMs = readStat.mtimeMs; // 2. User edits in editor // 3. Before save, stat again const preSaveStat: FileStat = { mtimeMs: 1700000000000, size: 50 }; // unchanged expect(hasConflict(readMtimeMs, preSaveStat)).toBe(false); // 4. Write via atomic const ops = atomicWriteOps('/test/file.ts', 'new content'); expect(ops.tmpPath).toContain('.agor-tmp'); }); it('full read-modify-check-write cycle — conflict detected', () => { // 1. Read file, record mtime const readMtimeMs = 1700000000000; // 2. External process modifies the file const preSaveStat: FileStat = { mtimeMs: 1700000002000, size: 80 }; // 3. Conflict detected — should warn user expect(hasConflict(readMtimeMs, preSaveStat)).toBe(true); }); it('after successful save, update readMtimeMs', () => { let readMtimeMs = 1700000000000; // Save succeeds, stat again to get new mtime const postSaveStat: FileStat = { mtimeMs: 1700000003000, size: 120 }; readMtimeMs = postSaveStat.mtimeMs; // No conflict on subsequent check expect(hasConflict(readMtimeMs, postSaveStat)).toBe(false); }); });