Reflète la refonte IDE en supervision légère, sans dupliquer l'éditeur : - statut git fin dans l'arbre (staged/unstaged/conflits + dernier commit) ; - commande « Open Worktree IDE » → deep-link /workspace/:repoId/:wt ; - commandes fetch / pull (ff-only|rebase) sur les worktrees ; - event WS session_archived + réglage showArchivedSessions (badge + filtre). README/CHANGELOG + tests (config/rest-client/ws-client).
149 lines
6.0 KiB
TypeScript
149 lines
6.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { BINARY_FRAME, FLOW, encodeBinaryFrame, type ClientMessage } from '@arboretum/shared';
|
||
import { ArbWsClient, type AttachmentSink, type RawSocket } from '../src/api/ws-client.js';
|
||
|
||
class FakeSocket implements RawSocket {
|
||
sent: string[] = [];
|
||
private openCb: (() => void) | null = null;
|
||
private textCb: ((t: string) => void) | null = null;
|
||
private binaryCb: ((d: Uint8Array) => void) | null = null;
|
||
private closeCb: (() => void) | null = null;
|
||
|
||
send(data: string): void {
|
||
this.sent.push(data);
|
||
}
|
||
close(): void {
|
||
this.closeCb?.();
|
||
}
|
||
onOpen(cb: () => void): void {
|
||
this.openCb = cb;
|
||
}
|
||
onText(cb: (t: string) => void): void {
|
||
this.textCb = cb;
|
||
}
|
||
onBinary(cb: (d: Uint8Array) => void): void {
|
||
this.binaryCb = cb;
|
||
}
|
||
onClose(cb: () => void): void {
|
||
this.closeCb = cb;
|
||
}
|
||
onError(): void {
|
||
/* non utilisé dans ces tests */
|
||
}
|
||
|
||
fireOpen(): void {
|
||
this.openCb?.();
|
||
}
|
||
fireText(msg: unknown): void {
|
||
this.textCb?.(JSON.stringify(msg));
|
||
}
|
||
fireBinary(data: Uint8Array): void {
|
||
this.binaryCb?.(data);
|
||
}
|
||
|
||
/** messages de contrôle envoyés, parsés et filtrés par type. */
|
||
controls<T extends ClientMessage['type']>(type: T): Array<Extract<ClientMessage, { type: T }>> {
|
||
return this.sent
|
||
.map((s) => JSON.parse(s) as ClientMessage)
|
||
.filter((m): m is Extract<ClientMessage, { type: T }> => m.type === type);
|
||
}
|
||
}
|
||
|
||
function makeNoopSink(): AttachmentSink {
|
||
return { data: () => {}, reset: () => {}, detached: () => {}, controlChanged: () => {} };
|
||
}
|
||
|
||
describe('ArbWsClient', () => {
|
||
let fake: FakeSocket;
|
||
let client: ArbWsClient;
|
||
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
fake = new FakeSocket();
|
||
client = new ArbWsClient({ socketFactory: () => fake });
|
||
client.configure('http://127.0.0.1:7317', 'arb_token');
|
||
client.connect();
|
||
fake.fireOpen();
|
||
fake.fireText({ type: 'hello_ok', protocol: 1, serverVersion: '1.8.0' });
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it('envoie hello puis sub après hello_ok', () => {
|
||
expect(fake.controls('hello')).toHaveLength(1);
|
||
expect(fake.controls('sub')[0]?.topics).toEqual(['sessions', 'worktrees', 'groups']);
|
||
});
|
||
|
||
it('corrèle attach→attached et délivre les OUTPUT au sink', async () => {
|
||
const chunks: Uint8Array[] = [];
|
||
const sink: AttachmentSink = { ...makeNoopSink(), data: (p) => chunks.push(p) };
|
||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink });
|
||
fake.fireText({ type: 'attached', channel: 1, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||
const att = await p;
|
||
expect(att.channel).toBe(1);
|
||
|
||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 1, new Uint8Array([104, 105]))); // "hi"
|
||
expect(chunks).toHaveLength(1);
|
||
expect([...(chunks[0] ?? [])]).toEqual([104, 105]);
|
||
});
|
||
|
||
it('ACK au seuil ACK_EVERY_BYTES', async () => {
|
||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
|
||
fake.fireText({ type: 'attached', channel: 7, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||
await p;
|
||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
|
||
const acks = fake.controls('ack');
|
||
expect(acks).toHaveLength(1);
|
||
expect(acks[0]).toMatchObject({ channel: 7, bytes: FLOW.ACK_EVERY_BYTES });
|
||
});
|
||
|
||
it('ACK traînant flushe un reliquat sous le seuil après 200ms', async () => {
|
||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
|
||
fake.fireText({ type: 'attached', channel: 2, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||
await p;
|
||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 2, new Uint8Array(1000)));
|
||
expect(fake.controls('ack')).toHaveLength(0); // sous le seuil → pas d’ACK immédiat
|
||
vi.advanceTimersByTime(200);
|
||
expect(fake.controls('ack')).toEqual([{ type: 'ack', channel: 2, bytes: 1000 }]);
|
||
});
|
||
|
||
it('RESYNC réinitialise le terminal et les compteurs d’ACK', async () => {
|
||
let resets = 0;
|
||
const sink: AttachmentSink = { ...makeNoopSink(), reset: () => (resets += 1) };
|
||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink });
|
||
fake.fireText({ type: 'attached', channel: 3, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||
await p;
|
||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 3, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
|
||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.RESYNC, 3, new Uint8Array([65]))); // reset + replay "A"
|
||
expect(resets).toBe(1);
|
||
// après resync, le replay ne compte pas : un nouvel OUTPUT au seuil ré-ACK à partir de 0
|
||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 3, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
|
||
const acks = fake.controls('ack');
|
||
expect(acks[acks.length - 1]).toMatchObject({ bytes: FLOW.ACK_EVERY_BYTES });
|
||
});
|
||
|
||
it('route session_archived vers onSessionArchived', () => {
|
||
const archived: string[] = [];
|
||
const f = new FakeSocket();
|
||
const c = new ArbWsClient({ socketFactory: () => f, onSessionArchived: (id) => archived.push(id) });
|
||
c.configure('http://127.0.0.1:7317', 'tok');
|
||
c.connect();
|
||
f.fireOpen();
|
||
f.fireText({ type: 'hello_ok', protocol: 1, serverVersion: '2.0.0' });
|
||
f.fireText({ type: 'session_archived', sessionId: 'sess-42' });
|
||
expect(archived).toEqual(['sess-42']);
|
||
});
|
||
|
||
it('envoie stdin/resize sur le bon canal', async () => {
|
||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
|
||
fake.fireText({ type: 'attached', channel: 5, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||
const att = await p;
|
||
att.sendStdin('ls\n');
|
||
att.resize(120, 40);
|
||
expect(fake.controls('stdin')[0]).toEqual({ type: 'stdin', channel: 5, data: 'ls\n' });
|
||
expect(fake.controls('resize')[0]).toEqual({ type: 'resize', channel: 5, cols: 120, rows: 40 });
|
||
});
|
||
});
|