Files
arboretum/packages/server/test/fs-watcher.test.ts
Johan LEROY e8d10b7ec0 feat(p11): temps réel complet (watcher checkout principal + topic settings)
P11-A — branche du checkout principal modifiée hors Arboretum :
- fs-watcher: pin « permanent » non évinçable (pinRepo/unpinRepo, repoPins dans evictIfNeeded)
- worktree-manager: armMainCheckoutWatchers() + arm/désarm sur addRepo/découverte/removeRepo/hidden
- index.ts: armMainCheckoutWatchers() dans runDaemon → git checkout CLI sur le principal → worktree_update <500ms sans watch client

P11-B — réglages en temps réel :
- protocole additif: type SettingsBroadcast (source unique, réutilisé par SettingsResponse), topic 'settings', message settings_update (validés parseClientMessage + gateway)
- core/settings-bus.ts (EventEmitter) ; routes/settings émet après PATCH ; gateway relaie aux abonnés 'settings'
- web: ws-client subscribeSettings + routage ; store settings applySettings/startRealtime ; AppShell abonne globalement ; SettingsView re-sync des drafts scalaires sans écraser une saisie en cours
- tests: protocol (topic settings) + fs-watcher (pinRepo non évincé) ; acceptance-p11.mjs (checkout principal <500ms + settings_update)
2026-06-27 14:08:44 +02:00

127 lines
4.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 nest 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 dun 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);
});
});