// Ordre des trames à l'attache : régression de « le terminal reste tout noir alors que la session // tourne ». Le replay d'attache est une frame BINAIRE ; le client n'apprend le numéro de canal // qu'avec le message `attached`, et jette toute frame binaire portant un canal inconnu. Émettre le // replay avant `attached` revenait donc à ne rien afficher jusqu'au prochain octet spontané du PTY, // c'est-à-dire jamais pour un TUI au repos (Claude à son prompt). // // On instrumente la gateway avec un faux socket et de faux bus d'événements : c'est le seul moyen // d'observer l'ORDRE réel des `socket.send` sans monter un vrai serveur WebSocket (couvert par // scripts/acceptance-p17.mjs). import { tmpdir } from 'node:os'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BINARY_FRAME, decodeBinaryFrame, PROTOCOL_VERSION } from '@arboretum/shared'; import { registerWsGateway } from '../src/ws/gateway.js'; import { PtyManager } from '../src/core/pty-manager.js'; import { openDb, type Db } from '../src/db/index.js'; vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' })); const ptyMock = vi.hoisted(() => ({ instances: [] as unknown[] })); vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => { let nextPid = 200_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> = []; constructor( readonly file: string, readonly args: string[], readonly opts: unknown, ) {} onData(cb: (d: string) => void): { dispose: () => void } { this.dataCbs.push(cb); return { dispose: () => {} }; } onExit(): { dispose: () => void } { return { dispose: () => {} }; } emitData(d: string): void { for (const cb of this.dataCbs) cb(d); } } return { default: { spawn: (file: string, args: string[], opts: unknown): FakePtyImpl => { const p = new FakePtyImpl(file, args, opts); ptyMock.instances.push(p); return p; }, }, }; }); /** Bus d'événements inerte : la gateway s'abonne à 7 services dont un seul nous intéresse. */ const inertBus = (): { on: () => void; off: () => void } => ({ on: () => {}, off: () => {} }); interface FakeSocket { readyState: number; OPEN: number; sent: Array; send(data: string | Uint8Array): void; on(event: string, cb: (...args: unknown[]) => void): void; ping(): void; terminate(): void; close(): void; emit(event: string, ...args: unknown[]): void; } function makeSocket(): FakeSocket { const handlers = new Map void>>(); return { readyState: 1, OPEN: 1, sent: [], send(data) { this.sent.push(data); }, on(event, cb) { const list = handlers.get(event) ?? []; list.push(cb); handlers.set(event, list); }, ping() {}, terminate() {}, close() {}, emit(event, ...args) { for (const cb of handlers.get(event) ?? []) cb(...args); }, }; } describe('gateway · ordre des trames à l’attache', () => { let db: Db; let manager: PtyManager; let socket: FakeSocket; beforeEach(() => { ptyMock.instances.length = 0; db = openDb(':memory:'); manager = new PtyManager(db); socket = makeSocket(); let handler: ((s: unknown, req: unknown) => void) | null = null; const app = { get: (_path: string, _opts: unknown, h: (s: unknown, req: unknown) => void) => { handler = h; }, }; registerWsGateway( app as never, manager, inertBus() as never, inertBus() as never, inertBus() as never, inertBus() as never, inertBus() as never, inertBus() as never, '0.0.0-test', ); handler!(socket, {}); socket.emit('message', Buffer.from(JSON.stringify({ type: 'hello', protocol: PROTOCOL_VERSION })), false); socket.sent.length = 0; // on ignore le hello_ok }); const send = (msg: unknown): void => socket.emit('message', Buffer.from(JSON.stringify(msg)), false); const texts = (): Array> => socket.sent.filter((f): f is string => typeof f === 'string').map((f) => JSON.parse(f) as Record); it('`attached` d’abord, replay binaire ENSUITE, sur le même canal', () => { const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' }); (ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('prompt$ '); send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 80, rows: 24 }); expect(socket.sent).toHaveLength(2); const attached = JSON.parse(socket.sent[0] as string) as { type: string; channel: number }; expect(attached.type).toBe('attached'); const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array); expect(frame.type).toBe(BINARY_FRAME.RESYNC); expect(frame.channel).toBe(attached.channel); expect(Buffer.from(frame.payload).toString()).toBe('prompt$ '); }); it('ring vide : le resync part quand même (il porte l’ordre de reset)', () => { const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' }); send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 80, rows: 24 }); expect(socket.sent).toHaveLength(2); const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array); expect(frame.type).toBe(BINARY_FRAME.RESYNC); expect(frame.payload.byteLength).toBe(0); }); it('attache sans écran : `attached` seul, aucune frame binaire', () => { const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' }); (ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('bruit'); send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 120, rows: 32, screen: false }); expect(texts().map((m) => m.type)).toEqual(['attached']); expect(socket.sent.every((f) => typeof f === 'string')).toBe(true); expect(texts()[0]).toMatchObject({ controlling: false }); }); it('un observer reçoit aussi son replay (il peint, lui)', () => { const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' }); (ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('ecran'); send({ type: 'attach', sessionId: summary.id, mode: 'observer', cols: 80, rows: 24 }); const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array); expect(frame.type).toBe(BINARY_FRAME.RESYNC); expect(Buffer.from(frame.payload).toString()).toBe('ecran'); }); it('session introuvable : erreur seule, pas de canal ni de frame binaire', () => { send({ type: 'attach', sessionId: 'inconnue', mode: 'interactive', cols: 80, rows: 24 }); expect(texts()).toEqual([{ type: 'error', code: 'NOT_FOUND', message: 'Cannot attach: NOT_FOUND' }]); }); });