import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { SessionSummary } from '@arboretum/shared'; import { DiscoveryService, mergeSessions } from '../src/core/discovery-service.js'; import { munge } from '../src/core/jsonl-discovery.js'; import { readProcStart } from '../src/core/session-registry.js'; import { PtyManager } from '../src/core/pty-manager.js'; import { openDb, hideSession, unhideSession, listHiddenSessionIds, type Db } from '../src/db/index.js'; function writeJsonl(projectsDir: string, cwd: string, sid: string): void { const dir = join(projectsDir, munge(cwd)); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, `${sid}.jsonl`), `${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`, ); } describe('DiscoveryService', () => { let projectsDir: string; let sessionsDir: string; let db: Db; let manager: PtyManager; let svc: DiscoveryService; beforeEach(() => { projectsDir = mkdtempSync(join(tmpdir(), 'arb-proj-')); sessionsDir = mkdtempSync(join(tmpdir(), 'arb-sess-')); db = openDb(':memory:'); manager = new PtyManager(db, sessionsDir); svc = new DiscoveryService({ db, ptyManager: manager, projectsDir, sessionsDir }); }); afterEach(() => { svc.stop(); rmSync(projectsDir, { recursive: true, force: true }); rmSync(sessionsDir, { recursive: true, force: true }); }); it('session morte (pas d’entrée registre) → resumable, non attachable', async () => { writeJsonl(projectsDir, '/home/u/dead', 'dead-sid'); await svc.refresh(); const s = svc.list().find((x) => x.id === 'dead-sid'); expect(s).toMatchObject({ source: 'discovered', live: false, resumable: true, attachable: false, cwd: '/home/u/dead' }); expect(svc.getDiscovered('dead-sid')?.cwd).toBe('/home/u/dead'); }); it('session vivante (registre + pid vivant) → live, non resumable, registryStatus exposé', async () => { writeJsonl(projectsDir, '/home/u/live', 'live-sid'); const ps = readProcStart(process.pid) as string; writeFileSync( join(sessionsDir, `${process.pid}.json`), JSON.stringify({ pid: process.pid, procStart: ps, sessionId: 'live-sid', cwd: '/home/u/live', status: 'waiting', waitingFor: 'permission prompt' }), ); await svc.refresh(); const s = svc.list().find((x) => x.id === 'live-sid'); expect(s).toMatchObject({ live: true, resumable: false, attachable: false, registryStatus: 'waiting', pid: process.pid }); }); it('exclut une session déjà connue d’Arboretum (managée / historique)', async () => { writeJsonl(projectsDir, '/home/u/managed', 'managed-sid'); db.prepare('INSERT INTO sessions (id, cwd, command, created_at, claude_session_id) VALUES (?, ?, ?, ?, ?)').run( 'uuid-1', '/home/u/managed', 'claude', new Date().toISOString(), 'managed-sid', ); await svc.refresh(); expect(svc.list().find((x) => x.id === 'managed-sid')).toBeUndefined(); }); it('marque hidden une session masquée mais la garde résoluble (resume/fork)', async () => { writeJsonl(projectsDir, '/home/u/old', 'old-sid'); hideSession(db, 'old-sid'); await svc.refresh(); const s = svc.list().find((x) => x.id === 'old-sid'); expect(s?.hidden).toBe(true); // toujours connue : la reprise/le fork doivent rester possibles expect(svc.getDiscovered('old-sid')?.cwd).toBe('/home/u/old'); // ré-affichage unhideSession(db, 'old-sid'); await svc.refresh(); expect(svc.list().find((x) => x.id === 'old-sid')?.hidden).toBe(false); }); it('hideSession est idempotent et listHiddenSessionIds reflète l’état', () => { hideSession(db, 'a'); hideSession(db, 'a'); hideSession(db, 'b'); expect(listHiddenSessionIds(db)).toEqual(new Set(['a', 'b'])); unhideSession(db, 'a'); expect(listHiddenSessionIds(db)).toEqual(new Set(['b'])); }); it('émet discovery_update sur changement uniquement', async () => { writeJsonl(projectsDir, '/home/u/x', 'sid-x'); const seen: SessionSummary[] = []; svc.on('discovery_update', (s) => seen.push(s)); await svc.refresh(); expect(seen.map((s) => s.id)).toContain('sid-x'); seen.length = 0; await svc.refresh(); // rien n'a changé → aucune ré-émission expect(seen).toHaveLength(0); }); }); describe('mergeSessions', () => { const mk = (id: string, claudeSessionId: string | null, source: 'managed' | 'discovered'): SessionSummary => ({ id, cwd: '/x', command: 'claude', title: null, status: 'exited', live: false, createdAt: '', endedAt: null, exitCode: null, clients: 0, source, claudeSessionId, pid: null, resumable: false, attachable: false, registryStatus: null, }); it('dédoublonne par claudeSessionId, managé prioritaire', () => { const managed = [mk('uuid', 'sid-1', 'managed')]; const discovered = [mk('sid-1', 'sid-1', 'discovered'), mk('sid-2', 'sid-2', 'discovered')]; expect(mergeSessions(managed, discovered).map((s) => s.id)).toEqual(['uuid', 'sid-2']); }); });