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