Files
arboretum/packages/shared/test/protocol.test.ts
T
johanleroy 8aea0ae32d fix(server, web, vscode): les terminaux ne restent plus noirs, panes côte à côte, git scopé au terminal
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.
2026-08-05 10:56:42 +02:00

307 lines
15 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 { describe, expect, it } from 'vitest';
import {
BINARY_FRAME,
decodeBinaryFrame,
encodeBinaryFrame,
parseClientMessage,
type ClientMessage,
} from '../src/protocol.js';
// PRNG déterministe pour le fuzz (reproductible d'un run à l'autre)
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const isU32 = (v: number): boolean => Number.isInteger(v) && v >= 0 && v <= 0xffffffff;
const isDim = (v: number): boolean => Number.isInteger(v) && v >= 2 && v <= 1000;
// Invariants que tout message accepté DOIT respecter, quel que soit l'input
function assertInvariants(msg: ClientMessage): void {
switch (msg.type) {
case 'hello':
expect(typeof msg.protocol).toBe('number');
break;
case 'attach':
expect(typeof msg.sessionId).toBe('string');
expect(['interactive', 'observer']).toContain(msg.mode);
expect(isDim(msg.cols)).toBe(true);
expect(isDim(msg.rows)).toBe(true);
break;
case 'detach':
expect(isU32(msg.channel)).toBe(true);
break;
case 'stdin':
expect(isU32(msg.channel)).toBe(true);
expect(typeof msg.data).toBe('string');
expect(msg.data.length).toBeLessThanOrEqual(65536);
break;
case 'answer':
expect(isU32(msg.channel)).toBe(true);
expect(['select', 'confirm', 'deny']).toContain(msg.action);
if (msg.action === 'select') {
expect(typeof msg.optionN).toBe('number');
expect(msg.optionN).toBeGreaterThanOrEqual(1);
expect(msg.optionN).toBeLessThanOrEqual(99);
}
break;
case 'resize':
expect(isU32(msg.channel)).toBe(true);
expect(isDim(msg.cols)).toBe(true);
expect(isDim(msg.rows)).toBe(true);
break;
case 'ack':
expect(isU32(msg.channel)).toBe(true);
expect(typeof msg.bytes).toBe('number');
expect(msg.bytes).toBeGreaterThanOrEqual(0);
break;
case 'sub':
expect(msg.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups' || t === 'settings')).toBe(true);
break;
case 'ping':
break;
}
}
describe('parseClientMessage · cas valides', () => {
it('hello', () => {
expect(parseClientMessage('{"type":"hello","protocol":1}')).toEqual({ type: 'hello', protocol: 1 });
});
it('attach avec dimensions aux bornes', () => {
const make = (cols: number, rows: number): string =>
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols, rows });
expect(parseClientMessage(make(2, 2))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 2, rows: 2, screen: true });
expect(parseClientMessage(make(1000, 1000))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 1000, rows: 1000, screen: true });
});
it('attach en mode observer', () => {
expect(parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 })))
.toEqual({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24, screen: true });
});
// `screen` est ADDITIF : absent, il vaut true (un vieux client garde le comportement d'origine).
// À false, l'attache n'affiche rien : ni contrôle du PTY, ni sortie, ni replay.
it('attach avec screen : défaut true, false respecté, valeur non booléenne ignorée', () => {
const attach = (screen?: unknown): unknown =>
parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 80, rows: 24, screen }));
expect(attach(false)).toMatchObject({ screen: false });
expect(attach(true)).toMatchObject({ screen: true });
expect(attach()).toMatchObject({ screen: true });
expect(attach('nope')).toMatchObject({ screen: true });
});
it('detach avec channel aux bornes u32', () => {
expect(parseClientMessage('{"type":"detach","channel":0}')).toEqual({ type: 'detach', channel: 0 });
expect(parseClientMessage(`{"type":"detach","channel":${0xffffffff}}`)).toEqual({ type: 'detach', channel: 0xffffffff });
});
it('stdin avec data à la taille max', () => {
const data = 'x'.repeat(65536);
expect(parseClientMessage(JSON.stringify({ type: 'stdin', channel: 1, data }))).toEqual({ type: 'stdin', channel: 1, data });
});
it('resize', () => {
expect(parseClientMessage('{"type":"resize","channel":3,"cols":120,"rows":32}'))
.toEqual({ type: 'resize', channel: 3, cols: 120, rows: 32 });
});
it('answer select / confirm / deny', () => {
expect(parseClientMessage('{"type":"answer","channel":2,"action":"select","optionN":3}'))
.toEqual({ type: 'answer', channel: 2, action: 'select', optionN: 3 });
expect(parseClientMessage('{"type":"answer","channel":2,"action":"confirm"}'))
.toEqual({ type: 'answer', channel: 2, action: 'confirm' });
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny"}'))
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
// optionN n'est porté que par 'select' (ignoré pour confirm/deny)
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny","optionN":3}'))
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
});
it('ack à zéro octet', () => {
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
});
it('sub avec topics sessions/worktrees/groups (et tableau vide accepté)', () => {
expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] });
expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees"]}')).toEqual({ type: 'sub', topics: ['sessions', 'worktrees'] });
expect(parseClientMessage('{"type":"sub","topics":["groups"]}')).toEqual({ type: 'sub', topics: ['groups'] });
expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees","groups"]}')).toEqual({
type: 'sub',
topics: ['sessions', 'worktrees', 'groups'],
});
expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
});
it('sub accepte le topic settings (P11) et rejette un topic inconnu', () => {
expect(parseClientMessage('{"type":"sub","topics":["settings"]}')).toEqual({ type: 'sub', topics: ['settings'] });
expect(parseClientMessage('{"type":"sub","topics":["sessions","settings"]}')).toEqual({ type: 'sub', topics: ['sessions', 'settings'] });
expect(parseClientMessage('{"type":"sub","topics":["bogus"]}')).toBeNull();
});
it('watch / unwatch (P7) avec repoId + path', () => {
expect(parseClientMessage('{"type":"watch","repoId":"r1","path":"/home/u/wt"}')).toEqual({ type: 'watch', repoId: 'r1', path: '/home/u/wt' });
expect(parseClientMessage('{"type":"unwatch","repoId":"r1","path":"/home/u/wt"}')).toEqual({ type: 'unwatch', repoId: 'r1', path: '/home/u/wt' });
});
it('ping (champs superflus ignorés)', () => {
expect(parseClientMessage('{"type":"ping","extra":42}')).toEqual({ type: 'ping' });
});
});
describe('parseClientMessage · cas malformés', () => {
it('JSON invalide ou non-objet', () => {
for (const raw of ['', 'not json', '{', '42', '"str"', 'null', 'true', '[]', '[1,2]']) {
expect(parseClientMessage(raw)).toBeNull();
}
});
it('type absent, non-string ou inconnu', () => {
expect(parseClientMessage('{}')).toBeNull();
expect(parseClientMessage('{"type":42}')).toBeNull();
expect(parseClientMessage('{"type":null}')).toBeNull();
expect(parseClientMessage('{"type":"unknown"}')).toBeNull();
});
it('hello sans protocol ou protocol non numérique', () => {
expect(parseClientMessage('{"type":"hello"}')).toBeNull();
expect(parseClientMessage('{"type":"hello","protocol":"1"}')).toBeNull();
});
it('watch / unwatch : repoId ou path manquant / mal typé', () => {
expect(parseClientMessage('{"type":"watch","path":"/x"}')).toBeNull();
expect(parseClientMessage('{"type":"watch","repoId":"r1"}')).toBeNull();
expect(parseClientMessage('{"type":"watch","repoId":"","path":"/x"}')).toBeNull();
expect(parseClientMessage('{"type":"unwatch","repoId":"r1","path":42}')).toBeNull();
});
it('attach : dimensions hors bornes ou non entières', () => {
const make = (over: Record<string, unknown>): string =>
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 80, rows: 24, ...over });
expect(parseClientMessage(make({ cols: 1 }))).toBeNull();
expect(parseClientMessage(make({ cols: 1001 }))).toBeNull();
expect(parseClientMessage(make({ rows: 0 }))).toBeNull();
expect(parseClientMessage(make({ rows: 1001 }))).toBeNull();
expect(parseClientMessage(make({ cols: 80.5 }))).toBeNull();
expect(parseClientMessage(make({ cols: -80 }))).toBeNull();
expect(parseClientMessage(make({ mode: 'admin' }))).toBeNull();
expect(parseClientMessage(make({ sessionId: 42 }))).toBeNull();
});
it('channel négatif, flottant ou > u32', () => {
expect(parseClientMessage('{"type":"detach","channel":-1}')).toBeNull();
expect(parseClientMessage('{"type":"detach","channel":1.5}')).toBeNull();
expect(parseClientMessage(`{"type":"detach","channel":${0x100000000}}`)).toBeNull();
expect(parseClientMessage('{"type":"stdin","channel":-1,"data":"x"}')).toBeNull();
expect(parseClientMessage('{"type":"ack","channel":-1,"bytes":0}')).toBeNull();
});
it('stdin : data trop longue ou non-string', () => {
expect(parseClientMessage(JSON.stringify({ type: 'stdin', channel: 1, data: 'x'.repeat(65537) }))).toBeNull();
expect(parseClientMessage('{"type":"stdin","channel":1,"data":42}')).toBeNull();
expect(parseClientMessage('{"type":"stdin","channel":1}')).toBeNull();
});
it('answer : action inconnue, optionN manquant/hors bornes, channel invalide', () => {
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select"}')).toBeNull(); // optionN requis
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":0}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":100}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":1.5}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":1,"action":"nope"}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":-1,"action":"deny"}')).toBeNull();
});
it('ack : bytes négatif ou non numérique', () => {
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull();
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull();
});
it('sub : topics inconnu ou non-tableau', () => {
expect(parseClientMessage('{"type":"sub","topics":["other"]}')).toBeNull();
expect(parseClientMessage('{"type":"sub","topics":["sessions","other"]}')).toBeNull();
expect(parseClientMessage('{"type":"sub","topics":"sessions"}')).toBeNull();
});
});
describe('parseClientMessage · fuzz rapide', () => {
it('ne lève jamais et tout message accepté respecte les invariants', () => {
const rand = mulberry32(0xa5b0e7);
const types = ['hello', 'attach', 'detach', 'stdin', 'answer', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
const values: unknown[] = [
undefined, null, true, false, 0, -1, 1, 1.5, 2, 99, 100, 999, 1000, 1001, 0xffffffff, 0x100000000,
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', 'select', 'confirm', 'deny', {}, [], ['sessions'],
['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000),
];
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T;
for (let i = 0; i < 1000; i++) {
const obj: Record<string, unknown> = { type: pick(types) };
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics', 'action', 'optionN']) {
if (rand() < 0.7) obj[key] = pick(values);
}
const raw = JSON.stringify(obj);
let msg: ClientMessage | null = null;
expect(() => {
msg = parseClientMessage(raw);
}).not.toThrow();
if (msg !== null) assertInvariants(msg);
}
});
});
describe('encode/decodeBinaryFrame', () => {
it('round-trip payload vide', () => {
const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 0, new Uint8Array(0));
expect(frame.byteLength).toBe(BINARY_FRAME.HEADER_BYTES);
const decoded = decodeBinaryFrame(frame);
expect(decoded.type).toBe(BINARY_FRAME.OUTPUT);
expect(decoded.channel).toBe(0);
expect(decoded.payload.byteLength).toBe(0);
});
it('round-trip gros payload (1 Mio) et channel max u32', () => {
const rand = mulberry32(0xdeadbe);
const payload = new Uint8Array(1024 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = Math.floor(rand() * 256);
const frame = encodeBinaryFrame(BINARY_FRAME.RESYNC, 0xffffffff, payload);
const decoded = decodeBinaryFrame(frame);
expect(decoded.type).toBe(BINARY_FRAME.RESYNC);
expect(decoded.channel).toBe(0xffffffff);
expect(Buffer.from(decoded.payload).equals(Buffer.from(payload))).toBe(true);
});
it('layout : type u8 puis channel u32 little-endian', () => {
const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 0x01020304, new Uint8Array([0xff]));
expect([...frame]).toEqual([0x01, 0x04, 0x03, 0x02, 0x01, 0xff]);
});
it('octets UTF-8 coupés en frontière de frame : le transport préserve les octets bruts', () => {
const text = Buffer.from('héllo 😀 wörld', 'utf8');
const cut = 8; // coupe au milieu d'une séquence multi-octets
const part1 = new Uint8Array(text.subarray(0, cut));
const part2 = new Uint8Array(text.subarray(cut));
const d1 = decodeBinaryFrame(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, part1));
const d2 = decodeBinaryFrame(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, part2));
const reassembled = Buffer.concat([d1.payload, d2.payload]);
expect(reassembled.equals(text)).toBe(true);
expect(reassembled.toString('utf8')).toBe('héllo 😀 wörld');
});
it('decode sur une vue avec byteOffset non nul (subarray d’un buffer plus large)', () => {
const payload = new Uint8Array([1, 2, 3]);
const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 42, payload);
const padded = new Uint8Array(frame.byteLength + 11);
padded.set(frame, 11);
const view = padded.subarray(11);
const decoded = decodeBinaryFrame(view);
expect(decoded.type).toBe(BINARY_FRAME.OUTPUT);
expect(decoded.channel).toBe(42);
expect([...decoded.payload]).toEqual([1, 2, 3]);
});
});