import { describe, expect, it, afterEach } from 'vitest'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { FsWatcherService, isIgnoredPath } from '../src/core/fs-watcher.js'; const dirs: string[] = []; const services: FsWatcherService[] = []; afterEach(async () => { for (const s of services.splice(0)) await s.closeAll(); for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); }); function makeTmpRepo(): string { const dir = mkdtempSync(join(tmpdir(), 'arb-fsw-')); dirs.push(dir); const run = (...args: string[]): void => void execFileSync('git', args, { cwd: dir, stdio: 'pipe' }); run('init', '-b', 'main'); run('config', 'user.email', 'test@arboretum.dev'); run('config', 'user.name', 'Test'); writeFileSync(join(dir, 'README.md'), '# test\n'); run('add', '-A'); run('commit', '-m', 'init'); return dir; } /** Attend le prochain event `worktree_fs_change` (ou rejette au timeout). */ function nextChange(s: FsWatcherService, timeoutMs = 4000): Promise<{ repoId: string; path: string }> { return new Promise((resolveP, reject) => { const t = setTimeout(() => reject(new Error('timeout: aucun worktree_fs_change')), timeoutMs); s.once('worktree_fs_change', (e) => { clearTimeout(t); resolveP(e); }); }); } describe('isIgnoredPath', () => { it('ignore .git interne sauf HEAD/index, et node_modules', () => { expect(isIgnoredPath('/r/.git/objects/ab/cd')).toBe(true); expect(isIgnoredPath('/r/.git/refs/heads/main')).toBe(true); expect(isIgnoredPath('/r/.git/HEAD')).toBe(false); expect(isIgnoredPath('/r/.git/index')).toBe(false); expect(isIgnoredPath('/r/node_modules/x/y.js')).toBe(true); expect(isIgnoredPath('/r/src/a.ts')).toBe(false); }); }); describe('FsWatcherService', () => { it('refcount : watch ouvre, unwatch ne ferme pas tant que le plafond n’est pas atteint', () => { const repo = makeTmpRepo(); const s = new FsWatcherService(); services.push(s); expect(s.isWatching('r1', repo)).toBe(false); s.watch('r1', repo); expect(s.isWatching('r1', repo)).toBe(true); expect(s.size()).toBe(1); s.unwatch('r1', repo); expect(s.isWatching('r1', repo)).toBe(true); // idle, pas encore évincé }); it('émet un worktree_fs_change débouncé à l’édition d’un fichier', async () => { const repo = makeTmpRepo(); const s = new FsWatcherService({ debounceMs: 50 }); services.push(s); s.watch('r1', repo); await s.whenReady('r1', repo); const p = nextChange(s); writeFileSync(join(repo, 'edited.txt'), 'hello\n'); const e = await p; expect(e.repoId).toBe('r1'); }); it('détecte un changement de branche externe (git checkout) via .git/HEAD', async () => { const repo = makeTmpRepo(); execFileSync('git', ['branch', 'feature'], { cwd: repo }); const s = new FsWatcherService({ debounceMs: 50 }); services.push(s); s.watch('r1', repo); await s.whenReady('r1', repo); const p = nextChange(s); execFileSync('git', ['checkout', 'feature'], { cwd: repo, stdio: 'pipe' }); const e = await p; expect(e.path).toContain('arb-fsw-'); }); it('le pool LRU évince les entrées idle au-delà du plafond, jamais les épinglées', () => { const s = new FsWatcherService({ maxWatchers: 2 }); services.push(s); const a = makeTmpRepo(); const b = makeTmpRepo(); const c = makeTmpRepo(); s.pinSession('a', a); // épinglé → jamais évincé s.watch('b', b); s.unwatch('b', b); // idle s.watch('c', c); // dépasse le plafond → évince le plus ancien idle (b) expect(s.isWatching('a', a)).toBe(true); expect(s.isWatching('c', c)).toBe(true); expect(s.isWatching('b', b)).toBe(false); }); it('pinRepo (checkout principal, P11) : jamais évincé par la LRU', () => { const s = new FsWatcherService({ maxWatchers: 1 }); services.push(s); const a = makeTmpRepo(); const b = makeTmpRepo(); s.pinRepo('a', a); // épingle permanente s.watch('b', b); s.unwatch('b', b); expect(s.isWatching('a', a)).toBe(true); // toujours là malgré le dépassement du plafond s.unpinRepo('a', a); const c = makeTmpRepo(); s.watch('c', c); // 'a' n'est plus épinglé et idle → évinçable expect(s.isWatching('a', a)).toBe(false); }); it('closeAll libère tout', async () => { const repo = makeTmpRepo(); const s = new FsWatcherService(); s.watch('r1', repo); expect(s.size()).toBe(1); await s.closeAll(); expect(s.size()).toBe(0); }); });