Un terminal pouvait rester tout noir alors que sa session tournait. Le PTY était vivant et avait bien écrit sa sortie : la panne était dans le transport. Le replay d'attache est une frame BINAIRE, mais un client n'apprend son numéro de canal qu'avec le message `attached` ; le serveur envoyait le replay AVANT, donc tout client le jetait sur un canal inconnu. Rien n'était peint, et un TUI au repos (Claude à son prompt) ne réémet jamais rien de lui-même. `attach()` renvoie désormais le replay et la gateway l'émet APRÈS `attached` : un seul correctif serveur répare le web, l'app de bureau et l'extension VS Code, qui portaient le même défaut client. Le resize de l'attache masquait le bug en provoquant un SIGWINCH, d'où son apparence intermittente. Seconde moitié du symptôme (« je tape et rien ne se passe ») : le dock montait avant la liste des sessions, en déduisait « non attachable » et s'attachait en observateur, à vie et en silence. Un pane n'attache plus avant de connaître sa session (`sessions.loaded`). Attaches sans écran : le message `attach` accepte un `screen` optionnel (défaut true). Un client qui n'affiche rien et veut seulement répondre à un dialogue ne prend plus le contrôle de la session, ne lui impose plus ses dimensions (ce qui figeait la géométrie du vrai terminal) et ne reçoit plus le flux pour le jeter. Rendre les pannes visibles : la raison d'un exit est écrite dans le terminal (`[arboretum] bash exited with code 3`) avant le détachement ; un repaint est forcé si rien n'arrive 1,2 s après l'attache, puis annoncé avec « Refresh screen » ; les refus de canal remontent à l'écran au lieu d'un console.warn ; le chemin du CLI claude est revalidé (périmé après une bascule nvm/asdf, le PTY mourait sans un octet). Colonnes de terminaux : le dock devient une rangée de colonnes redimensionnables (3 max), chacune avec ses onglets. Algèbre pure dans lib/dock-model.ts, cinq invariants documentés, ratios plutôt que pixels. `dockSessionIds` et `activeDockSessionId` deviennent des computed dérivés : aucun consommateur ni test existant ne change. Alt+clic ouvre à côté depuis les quatre panneaux. Le plafond de hauteur du dock suit le viewport au lieu d'un 640 px figé. Correctif préexistant au passage : PanelSplitter passait ses bornes par valeur, figées au premier rendu, alors que le clavier les relisait. Portée git : la vue Changements suit le worktree du terminal focalisé, ou tous les dépôts de son groupe pour une session de groupe, avec « tout voir » à un clic. L'index Git de la sidebar reste global (c'est la sortie d'une portée étroite) et le badge d'activité aussi (il sert à signaler le travail qu'on ne regarde pas). Seul le TERMINAL impose le contexte : le repli sur l'onglet éditeur, essayé d'abord, rétrécissait la vue multi-projet dès qu'on ouvrait un fichier. Vérifications : acceptance-p17.mjs prouve l'ordre des trames sur un vrai WebSocket (avec l'ancien ordre : 0 octet rejoué, échec), verify-terminals.mjs prouve par interaction réelle que le terminal peint, que deux colonnes coexistent, que la frappe atteint le bon PTY (fichier témoin par cwd) et que la vue suit le terminal.
720 lines
30 KiB
TypeScript
720 lines
30 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 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', screen = true): BindingSpies {
|
||
return {
|
||
channel: channelSeq++,
|
||
mode,
|
||
screen,
|
||
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('resumeTargetById : session claude morte → cwd + claudeSessionId + groupe ; null sinon', () => {
|
||
const d1 = mkdtempSync(join(tmpdir(), 'arb-rt-'));
|
||
try {
|
||
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1], groupId: 'grpR' });
|
||
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('cs-resume', summary.id);
|
||
// vivante → null (resume direct interdit ; fork passe par le même chemin)
|
||
expect(manager.resumeTargetById(summary.id)).toBeNull();
|
||
lastPty().emitExit(0);
|
||
// morte avec claudeSessionId connu → cible complète
|
||
expect(manager.resumeTargetById(summary.id)).toEqual({
|
||
cwd,
|
||
claudeSessionId: 'cs-resume',
|
||
addedDirs: [d1],
|
||
groupId: 'grpR',
|
||
});
|
||
// id inconnu → null
|
||
expect(manager.resumeTargetById('nope')).toBeNull();
|
||
} finally {
|
||
rmSync(d1, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('resumeTargetById : session bash morte (sans claudeSessionId) → null', () => {
|
||
const { summary, pty } = spawnBash();
|
||
pty.emitExit(0);
|
||
expect(manager.resumeTargetById(summary.id)).toBeNull();
|
||
});
|
||
|
||
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();
|
||
// on écrit volontairement PLUS que REPLAY_TAIL_BYTES (mais < RING_CAPACITY) pour vérifier
|
||
// le plafonnement de la queue rejouée. Tailles dérivées de la constante → robuste aux bumps.
|
||
const chunkSize = 256 * 1024;
|
||
const chunkCount = Math.ceil(REPLAY_TAIL_BYTES / chunkSize) + 2;
|
||
const chunks = Array.from({ length: chunkCount }, (_, i) => String.fromCharCode(65 + (i % 26)).repeat(chunkSize));
|
||
for (const c of chunks) pty.emitData(c);
|
||
|
||
const b = makeBinding('interactive');
|
||
const res = manager.attach(summary.id, b, 80, 24);
|
||
expect(res).toMatchObject({ ok: true, controlling: true });
|
||
|
||
// Le replay est RENVOYÉ (la gateway l'émet après `attached`), jamais envoyé par attach :
|
||
// une frame binaire qui précède `attached` tombe sur un canal inconnu du client et est jetée.
|
||
expect(b.sendResync).not.toHaveBeenCalled();
|
||
const payload = (res as { replay: Buffer }).replay;
|
||
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 → replay vide', () => {
|
||
const { summary } = spawnBash();
|
||
const b = makeBinding('interactive');
|
||
const res = manager.attach(summary.id, b, 80, 24);
|
||
expect((res as { replay: Buffer }).replay.length).toBe(0);
|
||
expect(b.sendResync).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('attache sans écran : ni contrôle, ni resize, ni sortie, ni replay', () => {
|
||
const { summary, pty } = spawnBash();
|
||
pty.emitData('hello');
|
||
const blind = makeBinding('interactive', false);
|
||
const res = manager.attach(summary.id, blind, 120, 32);
|
||
expect(res).toMatchObject({ ok: true, controlling: false });
|
||
expect((res as { replay: Buffer }).replay.length).toBe(0);
|
||
expect(pty.resize).not.toHaveBeenCalled();
|
||
|
||
// le vrai terminal ouvert ensuite prend bien le contrôle et impose SA géométrie
|
||
const real = makeBinding('interactive');
|
||
expect(manager.attach(summary.id, real, 200, 50)).toMatchObject({ ok: true, controlling: true });
|
||
expect(pty.resize).toHaveBeenCalledWith(200, 50);
|
||
|
||
pty.emitData('world');
|
||
expect(blind.sendOutput).not.toHaveBeenCalled();
|
||
expect(real.sendOutput).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
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)).toMatchObject({ ok: true, controlling: false });
|
||
expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas
|
||
|
||
expect(manager.attach(summary.id, a, 100, 30)).toMatchObject({ ok: true, controlling: true });
|
||
expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille
|
||
|
||
expect(manager.attach(summary.id, b, 200, 50)).toMatchObject({ 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 });
|
||
}
|
||
});
|
||
|
||
it('une notif sur le front montant busy→idle (Claude a terminé → disponible), après le debounce', async () => {
|
||
const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-idle-'));
|
||
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): void =>
|
||
writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status }));
|
||
|
||
// busy d'abord : front montant vers busy, pas de notif
|
||
writeReg('busy');
|
||
p.emitData('working…');
|
||
await sleep(260);
|
||
expect(notifies).toHaveLength(0);
|
||
|
||
// idle : Claude a terminé → front busy→idle → planifie la notif (debounce 1500ms)
|
||
writeReg('idle');
|
||
p.emitData('\x1b[2J\x1b[HDone.\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: null, url: `/sessions/${summary.id}` });
|
||
expect(notifies[0].body).toMatch(/available/i);
|
||
} 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);
|
||
// seul le rattrapage passe par sendResync : le replay d'attache est renvoyé à la gateway
|
||
expect(a.sendResync).toHaveBeenCalledTimes(1);
|
||
const payload = a.sendResync.mock.calls[0]![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)).toMatchObject({ 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 });
|
||
}
|
||
});
|
||
});
|
||
});
|