import { tmpdir } from 'node:os'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared'; import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js'; import type { PushPayload, PushService } from '../src/core/push-service.js'; import { openDb, type Db } from '../src/db/index.js'; // resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH. vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' })); interface FakePty { pid: number; file: string; args: string[]; opts: { cwd: string; cols: number; rows: number }; write: Mock; resize: Mock; pause: Mock; resume: Mock; emitData(data: string): void; emitExit(exitCode: number, signal?: number): void; } const ptyMock = vi.hoisted(() => ({ instances: [] as unknown[] })); vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => { let nextPid = 100_000; class FakePtyImpl { pid = nextPid++; write = vi.fn(); resize = vi.fn(); pause = vi.fn(); resume = vi.fn(); kill = vi.fn(); private dataCbs: Array<(d: string) => void> = []; private exitCbs: Array<(e: { exitCode: number; signal?: number }) => void> = []; constructor( readonly file: string, readonly args: string[], readonly opts: unknown, ) {} onData(cb: (d: string) => void): { dispose: () => void } { this.dataCbs.push(cb); return { dispose: () => {} }; } onExit(cb: (e: { exitCode: number; signal?: number }) => void): { dispose: () => void } { this.exitCbs.push(cb); return { dispose: () => {} }; } emitData(d: string): void { for (const cb of this.dataCbs) cb(d); } emitExit(exitCode: number, signal?: number): void { for (const cb of this.exitCbs) cb(signal === undefined ? { exitCode } : { exitCode, signal }); } } return { default: { spawn: (file: string, args: string[], opts: unknown): FakePtyImpl => { const p = new FakePtyImpl(file, args, opts); ptyMock.instances.push(p); return p; }, }, }; }); type BindingSpies = ClientBinding & { sendOutput: Mock<(payload: Buffer) => void>; sendResync: Mock<(payload: Buffer) => void>; onDetached: Mock<(reason: 'session_exit' | 'replaced') => void>; onControlChanged: Mock<(controlling: boolean) => void>; }; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); let channelSeq = 1; function makeBinding(mode: 'interactive' | 'observer'): BindingSpies { return { channel: channelSeq++, mode, controlling: false, sentBytes: 0, ackedBytes: 0, lagging: false, sendOutput: vi.fn(), sendResync: vi.fn(), onDetached: vi.fn(), onControlChanged: vi.fn(), }; } function lastPty(): FakePty { return ptyMock.instances.at(-1) as FakePty; } describe('PtyManager (pty mocké)', () => { let db: Db; let manager: PtyManager; const cwd = tmpdir(); beforeEach(() => { ptyMock.instances.length = 0; db = openDb(':memory:'); manager = new PtyManager(db); }); function spawnBash(): { summary: SessionSummary; pty: FakePty } { const summary = manager.spawn({ cwd, command: 'bash' }); return { summary, pty: lastPty() }; } describe('spawn', () => { it('émet session_update, insère en base et lance le pty avec le bon spec', () => { const updates: SessionSummary[] = []; manager.on('session_update', (s) => updates.push(s)); const { summary, pty } = spawnBash(); expect(updates).toHaveLength(1); expect(updates[0]).toEqual(summary); expect(summary.status).toBe('running'); expect(summary.live).toBe(true); expect(summary.clients).toBe(0); expect(summary.command).toBe('bash'); expect(pty.file).toBe('bash'); expect(pty.args).toEqual(['--norc']); expect(pty.opts.cwd).toBe(cwd); expect(pty.opts.cols).toBe(120); expect(pty.opts.rows).toBe(32); const row = db.prepare('SELECT cwd, command FROM sessions WHERE id = ?').get(summary.id) as { cwd: string; command: string }; expect(row).toEqual({ cwd, command: 'bash' }); expect(manager.get(summary.id)?.id).toBe(summary.id); }); it('cwd inexistant → erreur statusCode 400, rien en base', () => { expect(() => manager.spawn({ cwd: '/definitely/not/a/dir', command: 'bash' })).toThrowError( expect.objectContaining({ statusCode: 400 }), ); const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number }; expect(row.n).toBe(0); }); it('summarize expose les champs P2 (source/attachable/resumable/pid)', () => { const { summary } = spawnBash(); expect(summary).toMatchObject({ source: 'managed', attachable: true, resumable: false, registryStatus: null, claudeSessionId: null, }); expect(summary.pid).toBeGreaterThan(0); }); }); describe('resume / fork (P2)', () => { it('resume → pty claude avec --resume, command forcé à claude, resumed_from persisté', () => { const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'abc-123' } }); const p = lastPty(); expect(summary.command).toBe('claude'); expect(summary.source).toBe('managed'); expect(summary.attachable).toBe(true); expect(p.file).toBe('/usr/bin/claude'); expect(p.args).toEqual(['--resume', 'abc-123']); const row = db.prepare('SELECT resumed_from, command FROM sessions WHERE id = ?').get(summary.id) as { resumed_from: string | null; command: string; }; expect(row).toEqual({ resumed_from: 'abc-123', command: 'claude' }); }); it('fork → --resume --fork-session', () => { manager.spawn({ cwd, resume: { claudeSessionId: 'xyz', fork: true } }); expect(lastPty().args).toEqual(['--resume', 'xyz', '--fork-session']); }); it('session claude morte avec claudeSessionId connu → resumable dans list()', () => { const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'sid-known' } }); // simule la capture du claudeSessionId (normalement résolue via le registre) db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('sid-known', summary.id); lastPty().emitExit(0); const listed = manager.list().find((s) => s.id === summary.id); expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' }); }); it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => { vi.useFakeTimers(); const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-')); try { const m = new PtyManager(db, sessDir); const summary = m.spawn({ cwd, command: 'claude' }); const p = lastPty(); writeFileSync(join(sessDir, `${p.pid}.json`), JSON.stringify({ pid: p.pid, sessionId: 'captured-sid', status: 'idle' })); expect(m.findLiveByClaudeSessionId('captured-sid')).toBeNull(); // pas encore capturé vi.advanceTimersByTime(400); const found = m.findLiveByClaudeSessionId('captured-sid'); expect(found?.id).toBe(summary.id); expect(found?.claudeSessionId).toBe('captured-sid'); } finally { vi.useRealTimers(); rmSync(sessDir, { recursive: true, force: true }); } }); }); describe('attach', () => { it('session inconnue → NOT_FOUND', () => { const res = manager.attach('nope', makeBinding('interactive'), 80, 24); expect(res).toEqual({ ok: false, code: 'NOT_FOUND' }); }); it('resync à l’attach = queue du ring (REPLAY_TAIL_BYTES max)', () => { const { summary, pty } = spawnBash(); const chunks = ['A', 'B', 'C'].map((c) => c.repeat(100 * 1024)); for (const c of chunks) pty.emitData(c); const b = makeBinding('interactive'); const res = manager.attach(summary.id, b, 80, 24); expect(res).toEqual({ ok: true, controlling: true }); expect(b.sendResync).toHaveBeenCalledTimes(1); const payload = b.sendResync.mock.calls[0]![0]!; const full = Buffer.from(chunks.join(''), 'ascii'); expect(payload.length).toBe(REPLAY_TAIL_BYTES); expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true); // compteurs de flow control remis à zéro après le resync initial expect(b.sentBytes).toBe(0); expect(b.ackedBytes).toBe(0); }); it('ring vide → resync avec payload vide', () => { const { summary } = spawnBash(); const b = makeBinding('interactive'); manager.attach(summary.id, b, 80, 24); expect(b.sendResync).toHaveBeenCalledTimes(1); expect(b.sendResync.mock.calls[0]![0]!.length).toBe(0); }); it('contrôle au premier interactif seulement, les observers ne comptent pas', () => { const { summary, pty } = spawnBash(); const obs = makeBinding('observer'); const a = makeBinding('interactive'); const b = makeBinding('interactive'); expect(manager.attach(summary.id, obs, 80, 24)).toEqual({ ok: true, controlling: false }); expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas expect(manager.attach(summary.id, a, 100, 30)).toEqual({ ok: true, controlling: true }); expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille expect(manager.attach(summary.id, b, 200, 50)).toEqual({ ok: true, controlling: false }); expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur expect(manager.get(summary.id)?.clients).toBe(3); }); }); describe('write / resize', () => { it('observer read-only : write refusé', () => { const { summary, pty } = spawnBash(); const obs = makeBinding('observer'); manager.attach(summary.id, obs, 80, 24); expect(manager.write(summary.id, obs, 'ls\r')).toBe('not_controlling'); expect(pty.write).not.toHaveBeenCalled(); }); it('tout interactif peut écrire (même non contrôleur), session inconnue → gone', () => { const { summary, pty } = spawnBash(); const a = makeBinding('interactive'); const b = makeBinding('interactive'); manager.attach(summary.id, a, 80, 24); manager.attach(summary.id, b, 80, 24); expect(manager.write(summary.id, a, 'echo a\r')).toBe('ok'); expect(manager.write(summary.id, b, 'echo b\r')).toBe('ok'); expect(pty.write).toHaveBeenCalledTimes(2); expect(manager.write('nope', a, 'x')).toBe('gone'); }); it('resize ignoré pour les non-contrôleurs', () => { const { summary, pty } = spawnBash(); const a = makeBinding('interactive'); const b = makeBinding('interactive'); manager.attach(summary.id, a, 80, 24); manager.attach(summary.id, b, 80, 24); pty.resize.mockClear(); manager.resize(summary.id, b, 50, 20); expect(pty.resize).not.toHaveBeenCalled(); manager.resize(summary.id, a, 90, 28); expect(pty.resize).toHaveBeenCalledWith(90, 28); }); }); describe('answer (P4-A)', () => { it('select → "N\\r" si l’option existe, deny → Esc ; rejets gone/not_controlling/invalid', async () => { const sessDir = mkdtempSync(join(tmpdir(), 'arb-answer-')); const m = new PtyManager(db, sessDir); try { const summary = m.spawn({ cwd, command: 'claude' }); const p = lastPty(); // écran : dialogue de permission à 2 options (la 1re est sélectionnée ❯) p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n❯ 1. Yes\r\n2. No\r\nEsc to cancel\r\n'); writeFileSync( join(sessDir, `${p.pid}.json`), JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status: 'waiting', waitingFor: 'permission prompt' }), ); await sleep(260); // laisse le screen reader + le debounce (200ms) du tracker établir l’état const a = makeBinding('interactive'); m.attach(summary.id, a, 80, 24); p.write.mockClear(); // option inexistante → invalid (anti-frappe fantôme), aucun write expect(m.answer(summary.id, a, 'select', 9)).toBe('invalid'); expect(p.write).not.toHaveBeenCalled(); // option existante → "1\r" expect(m.answer(summary.id, a, 'select', 1)).toBe('ok'); expect(p.write).toHaveBeenCalledWith('1\r'); // deny → Esc expect(m.answer(summary.id, a, 'deny')).toBe('ok'); expect(p.write).toHaveBeenCalledWith('\x1b'); // observer read-only const obs = makeBinding('observer'); m.attach(summary.id, obs, 80, 24); expect(m.answer(summary.id, obs, 'deny')).toBe('not_controlling'); // session inconnue → gone expect(m.answer('nope', a, 'deny')).toBe('gone'); } finally { m.shutdown(); rmSync(sessDir, { recursive: true, force: true }); } }); it('session sans état waiting (bash, pas de tracker) → invalid', () => { const { summary, pty } = spawnBash(); const a = makeBinding('interactive'); manager.attach(summary.id, a, 80, 24); pty.write.mockClear(); expect(manager.answer(summary.id, a, 'deny')).toBe('invalid'); expect(manager.answer(summary.id, a, 'select', 1)).toBe('invalid'); expect(pty.write).not.toHaveBeenCalled(); }); }); describe('push trigger (P4-B)', () => { it('une notif sur le front montant busy→waiting, après le debounce ; pas de notif en restant busy', async () => { const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-')); const notifies: PushPayload[] = []; const fakePush = { notify: async (p: PushPayload) => { notifies.push(p); }, } as unknown as PushService; const m = new PtyManager(db, sessDir, fakePush); try { const summary = m.spawn({ cwd, command: 'claude' }); const p = lastPty(); const regFile = join(sessDir, `${p.pid}.json`); const writeReg = (status: string, waitingFor?: string): void => writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status, ...(waitingFor ? { waitingFor } : {}) })); // busy : front montant vers busy, pas de notif writeReg('busy'); p.emitData('working…'); await sleep(260); expect(notifies).toHaveLength(0); // waiting + écran permission : front montant vers waiting → planifie la notif (debounce 1500ms) writeReg('waiting', 'permission prompt'); p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n❯ 1. Yes\r\n2. No\r\n'); await sleep(260); expect(notifies).toHaveLength(0); // pas encore : debounce en cours await sleep(1500); // dépasse le debounce expect(notifies).toHaveLength(1); expect(notifies[0]).toMatchObject({ sessionId: summary.id, kind: 'permission', url: `/sessions/${summary.id}` }); } finally { m.shutdown(); rmSync(sessDir, { recursive: true, force: true }); } }); }); describe('flow control', () => { it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => { const { summary, pty } = spawnBash(); const a = makeBinding('interactive'); const b = makeBinding('interactive'); manager.attach(summary.id, a, 80, 24); manager.attach(summary.id, b, 80, 24); const chunk = 'x'.repeat(200 * 1024); // 200 Kio ASCII pty.emitData(chunk); // a=200K, b=200K — sous HIGH (384K) expect(pty.pause).not.toHaveBeenCalled(); manager.ack(summary.id, b, 200 * 1024); // b rattrape tout pty.emitData(chunk); // a=400K > HIGH, b=200K < HIGH expect(pty.pause).not.toHaveBeenCalled(); // un seul interactif au-dessus ne suffit pas pty.emitData(chunk); // a=600K, b=400K → TOUS au-dessus de HIGH expect(a.sentBytes - a.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK); expect(b.sentBytes - b.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK); expect(pty.pause).toHaveBeenCalledTimes(1); expect(pty.resume).not.toHaveBeenCalled(); manager.ack(summary.id, b, 600 * 1024); // outstanding b = 0 < LOW expect(pty.resume).toHaveBeenCalledTimes(1); expect(pty.pause).toHaveBeenCalledTimes(1); // pas de re-pause intempestive }); it('les observers ne freinent jamais le PTY', () => { const { summary, pty } = spawnBash(); const a = makeBinding('interactive'); const obs = makeBinding('observer'); manager.attach(summary.id, a, 80, 24); manager.attach(summary.id, obs, 80, 24); const chunk = 'y'.repeat(200 * 1024); for (let i = 0; i < 3; i++) { pty.emitData(chunk); manager.ack(summary.id, a, a.sentBytes); // l'interactif suit le rythme } expect(obs.sentBytes - obs.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK); expect(pty.pause).not.toHaveBeenCalled(); }); it('session sans interactif (observers seuls) : jamais de pause', () => { const { summary, pty } = spawnBash(); const obs = makeBinding('observer'); manager.attach(summary.id, obs, 80, 24); pty.emitData('z'.repeat(500 * 1024)); expect(pty.pause).not.toHaveBeenCalled(); }); }); describe('client lagging', () => { it('> LAGGING_BYTES sans ACK → flux coupé, puis resync au rattrapage', () => { const { summary, pty } = spawnBash(); const a = makeBinding('interactive'); manager.attach(summary.id, a, 80, 24); const mib = 1024 * 1024; const chunks = ['A', 'B', 'C', 'D'].map((c) => c.repeat(mib)); pty.emitData(chunks[0]!); // outstanding 1 Mio pty.emitData(chunks[1]!); // outstanding 2 Mio — pas strictement > LAGGING_BYTES expect(a.lagging).toBe(false); expect(a.sendOutput).toHaveBeenCalledTimes(2); pty.emitData(chunks[2]!); // outstanding 3 Mio > 2 Mio → lagging expect(a.lagging).toBe(true); expect(a.sendOutput).toHaveBeenCalledTimes(3); // ce chunk est parti avant le marquage // le seul interactif est lagging : le PTY ne doit pas rester en pause expect(pty.resume).toHaveBeenCalled(); pty.emitData(chunks[3]!); // flux coupé pour le client lagging expect(a.sendOutput).toHaveBeenCalledTimes(3); manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW expect(a.lagging).toBe(false); expect(a.sendResync).toHaveBeenCalledTimes(2); // attach + rattrapage const payload = a.sendResync.mock.calls[1]![0]!; const full = Buffer.from(chunks.join(''), 'ascii'); expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true); expect(a.sentBytes).toBe(0); expect(a.ackedBytes).toBe(0); // le flux reprend normalement après le resync pty.emitData('tail'); expect(a.sendOutput).toHaveBeenCalledTimes(4); expect(a.sendOutput.mock.calls[3]![0]!.toString('ascii')).toBe('tail'); }); }); describe('detach et transfert de contrôle', () => { it('detach du contrôleur → transfert au prochain interactif (onControlChanged)', () => { const { summary } = spawnBash(); const a = makeBinding('interactive'); const b = makeBinding('interactive'); const obs = makeBinding('observer'); manager.attach(summary.id, a, 80, 24); manager.attach(summary.id, obs, 80, 24); manager.attach(summary.id, b, 80, 24); expect(a.controlling).toBe(true); expect(b.controlling).toBe(false); manager.detach(summary.id, a); expect(b.controlling).toBe(true); expect(b.onControlChanged).toHaveBeenCalledExactlyOnceWith(true); expect(obs.onControlChanged).not.toHaveBeenCalled(); expect(manager.get(summary.id)?.clients).toBe(2); }); it('detach d’un non-contrôleur : aucun transfert', () => { const { summary } = spawnBash(); const a = makeBinding('interactive'); const b = makeBinding('interactive'); manager.attach(summary.id, a, 80, 24); manager.attach(summary.id, b, 80, 24); manager.detach(summary.id, b); expect(a.controlling).toBe(true); expect(a.onControlChanged).not.toHaveBeenCalled(); }); it('detach du dernier interactif : un observer ne récupère jamais le contrôle', () => { const { summary } = spawnBash(); const a = makeBinding('interactive'); const obs = makeBinding('observer'); manager.attach(summary.id, a, 80, 24); manager.attach(summary.id, obs, 80, 24); manager.detach(summary.id, a); expect(obs.controlling).toBe(false); expect(obs.onControlChanged).not.toHaveBeenCalled(); }); it('un nouvel interactif après detach du contrôleur reprend le contrôle', () => { const { summary } = spawnBash(); const a = makeBinding('interactive'); manager.attach(summary.id, a, 80, 24); manager.detach(summary.id, a); const b = makeBinding('interactive'); expect(manager.attach(summary.id, b, 80, 24)).toEqual({ ok: true, controlling: true }); }); }); describe('exit', () => { it('onDetached(session_exit) pour tous, DB mise à jour, événements émis', () => { const { summary, pty } = spawnBash(); const a = makeBinding('interactive'); const obs = makeBinding('observer'); manager.attach(summary.id, a, 80, 24); manager.attach(summary.id, obs, 80, 24); const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = []; const updates: SessionSummary[] = []; manager.on('session_exit', (e) => exits.push(e)); manager.on('session_update', (s) => updates.push(s)); pty.emitExit(0); expect(a.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit'); expect(obs.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit'); expect(exits).toEqual([{ sessionId: summary.id, exitCode: 0, signal: null }]); expect(updates).toHaveLength(1); expect(updates[0]).toMatchObject({ id: summary.id, status: 'exited', live: false, exitCode: 0 }); const row = db.prepare('SELECT ended_at, exit_code FROM sessions WHERE id = ?').get(summary.id) as { ended_at: string | null; exit_code: number | null }; expect(row.ended_at).not.toBeNull(); expect(row.exit_code).toBe(0); expect(manager.get(summary.id)).toBeNull(); const listed = manager.list().find((s) => s.id === summary.id); expect(listed).toMatchObject({ status: 'exited', live: false, exitCode: 0 }); }); it('exit avec signal : propagé dans session_exit', () => { const { summary, pty } = spawnBash(); const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = []; manager.on('session_exit', (e) => exits.push(e)); pty.emitExit(1, 15); expect(exits).toEqual([{ sessionId: summary.id, exitCode: 1, signal: 15 }]); }); it('après exit : write → gone, attach → NOT_FOUND', () => { const { summary, pty } = spawnBash(); const a = makeBinding('interactive'); manager.attach(summary.id, a, 80, 24); pty.emitExit(0); expect(manager.write(summary.id, a, 'x')).toBe('gone'); expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' }); }); }); describe('session de groupe multi-repo (P6)', () => { it('spawn avec addDirs : --add-dir au pty, addedDirs + groupId résumés et persistés', () => { const d1 = mkdtempSync(join(tmpdir(), 'arb-g1-')); const d2 = mkdtempSync(join(tmpdir(), 'arb-g2-')); try { const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d2], groupId: 'grp1' }); expect(lastPty().args).toEqual(['--add-dir', d1, '--add-dir', d2]); expect(summary.addedDirs).toEqual([d1, d2]); expect(summary.groupId).toBe('grp1'); const row = db.prepare('SELECT added_dirs, group_id FROM sessions WHERE id = ?').get(summary.id) as { added_dirs: string | null; group_id: string | null; }; expect(JSON.parse(row.added_dirs as string)).toEqual([d1, d2]); expect(row.group_id).toBe('grp1'); } finally { rmSync(d1, { recursive: true, force: true }); rmSync(d2, { recursive: true, force: true }); } }); it('dédoublonne et écarte le cwd primaire des addDirs', () => { const d1 = mkdtempSync(join(tmpdir(), 'arb-g4-')); try { const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d1, cwd] }); expect(summary.addedDirs).toEqual([d1]); } finally { rmSync(d1, { recursive: true, force: true }); } }); it('rejette un addDir inexistant (400)', () => { expect(() => manager.spawn({ cwd, command: 'bash', addDirs: ['/no/such/dir/xyz-arb'] })).toThrow(); }); it('groupSessionContext renvoie les addedDirs/groupId persistés (resume)', () => { const d1 = mkdtempSync(join(tmpdir(), 'arb-g5-')); try { const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1], groupId: 'grpX' }); // simule la capture du claudeSessionId (normalement résolue via le registre) db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('cs-1', summary.id); expect(manager.groupSessionContext('cs-1')).toEqual({ addedDirs: [d1], groupId: 'grpX' }); expect(manager.groupSessionContext('unknown')).toBeNull(); } finally { rmSync(d1, { recursive: true, force: true }); } }); }); });