Files
arboretum/packages/server/test/pty-manager.test.ts
Johan LEROY c43fb25c94 P4-A: commande WS answer — répondre aux dialogues sans clavier
Nouvelle commande WS de haut niveau `answer {channel, action, optionN?}`
traduite côté serveur en keystrokes (chiffre+Entrée / Entrée / Esc) et
validée contre le dialogue courant du tracker (anti-frappe fantôme mobile).

- protocol.ts : message `answer` + validation parseClientMessage + ErrorCode INVALID_ANSWER
- pty-manager.answer() : mappe l'intention en write PTY, réutilise le modèle mono-utilisateur
- gateway : case `answer` (NOT_CONTROLLING / SESSION_EXITED / INVALID_ANSWER)
- web : Attachment.answer(), DialogPrompt.vue (attache interactive légère sink no-op),
  intégré dans SessionView, SessionsListView, WorktreeCard + i18n en/fr
- tests : parseClientMessage (answer valide/malformé + fuzz), pty-manager.answer (163 verts)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:05:27 +02:00

538 lines
22 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 { 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('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' });
});
});
});