Files
arboretum/packages/server/test/pty-manager.test.ts
Johan LEROY c6ea9f7930
All checks were successful
Deploy site (production) / build-and-deploy (push) Successful in 21s
feat(groups): session Claude unique par groupe via --add-dir (P6)
Un groupe lance désormais UNE seule session Claude couvrant tous ses
repos/worktrees (--add-dir) au lieu d'une session par repo, pour
travailler en simultané dans une conversation partagée. Le transport WS,
le flow control et l'attach sont inchangés (toujours 1 session, 1 channel).

- shared: SessionSummary.addedDirs/groupId (champs additifs optionnels) ;
  CreateGroupSessionRequest / GroupSessionResponse.
- server: --add-dir dans claude-launcher ; spawn({addDirs,groupId}) +
  persistance (migration #8 : sessions.added_dirs/group_id) ;
  POST /api/v1/groups/:id/session (résolution des dirs cote serveur) ;
  resume re-relie les dirs ; deleteGroup nullifie group_id.
- web: GroupSessionModal (modes feature / checkouts principaux) ;
  createGroupFeature/createGroupSession ; badge « groupe · N depots » ;
  sessionsInGroup dedupliquee.
- site: textes EN/FR « une seule session Claude » + diagramme hub.
- tests: claude-launcher, pty-manager (groupe), group-manager ;
  acceptation P5 etendue (session de groupe multi-repo). 272 tests verts.
2026-06-22 10:06:23 +02:00

627 lines
26 KiB
TypeScript
Raw 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 { 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<void> => 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 <id> --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 à lattach = 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 loption 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 dun 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 });
}
});
});
});