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)
127 lines
4.6 KiB
TypeScript
127 lines
4.6 KiB
TypeScript
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);
|
||
});
|
||
});
|