/** * F3 — the WS live client connection state machine + reconnection: * - disconnected (socket close) ⇒ `reconnecting`, a notice is written to xterm; * - on reconnect, the tracked terminal is re-attached with its `lastSeq` and the * bounded scrollback is repainted; state returns to `connected`; * - a `terminal.status` `exited` frame notifies the session and stops tracking * (so a later disconnect does not try to re-attach a dead PTY). */ import { describe, it, expect, vi } from "vitest"; import { WsLiveClient, type WebSocketLike, type ConnectionState } from "./wsLiveClient"; import { bytesToBase64 } from "./frames"; class FakeSocket implements WebSocketLike { sent: string[] = []; onopen: (() => void) | null = null; onmessage: ((event: { data: string }) => void) | null = null; onerror: ((event: unknown) => void) | null = null; onclose: (() => void) | null = null; send(data: string): void { this.sent.push(data); } close(): void { this.onclose?.(); } receive(frame: unknown): void { this.onmessage?.({ data: JSON.stringify(frame) }); } } function harness() { const sockets: FakeSocket[] = []; let timerFn: (() => void) | null = null; const ws = new WsLiveClient({ wsUrl: "wss://host", reconnectBaseMs: 1, socketFactory: () => { const s = new FakeSocket(); sockets.push(s); queueMicrotask(() => s.onopen?.()); return s; }, setTimeoutImpl: (fn) => { timerFn = fn; return 1; }, clearTimeoutImpl: () => { timerFn = null; }, }); const states: ConnectionState[] = []; ws.onConnectionStateChange((s) => states.push(s)); return { ws, sockets, states, fireTimer: () => { const fn = timerFn; timerFn = null; fn?.(); }, hasTimer: () => timerFn !== null, }; } function attachedAck(id: string, sessionId: string, nextSeq: number, scroll?: Uint8Array) { return { kind: "terminal.attached", replyTo: id, payload: { session: { sessionId, rows: 30, cols: 120 }, scrollback: scroll ? [{ seq: 0, bytesBase64: bytesToBase64(scroll) }] : [], nextSeq, status: "running", gap: false, }, }; } async function attach( ws: WsLiveClient, sockets: FakeSocket[], sessionId: string, onData: (b: Uint8Array) => void, onStatus?: (s: string, c?: number | null) => void, ): Promise { const p = ws.attachTerminal({ sessionId, onData, onStatus }); // The socket is created lazily by the factory inside ensureConnected. await vi.waitFor(() => expect(sockets[0]?.sent.length ?? 0).toBeGreaterThan(0)); const sent = JSON.parse(sockets[0].sent[0]); sockets[0].receive(attachedAck(sent.id, sessionId, 0)); await p; } const decode = (b: Uint8Array) => new TextDecoder().decode(b); describe("WsLiveClient connection state + reconnection", () => { it("goes connecting → connected on the first attach", async () => { const h = harness(); await attach(h.ws, h.sockets, "s1", () => {}); expect(h.ws.getConnectionState()).toBe("connected"); expect(h.states).toContain("connected"); }); it("on socket close it reconnects, re-attaches with lastSeq and repaints scrollback", async () => { const h = harness(); const chunks: Uint8Array[] = []; await attach(h.ws, h.sockets, "s1", (b) => chunks.push(b)); // Advance lastSeq via an output frame (seq 5). h.sockets[0].receive({ kind: "terminal.output", payload: { sessionId: "s1", seq: 5, bytesBase64: bytesToBase64(new Uint8Array([120])) }, }); // Drop the socket → reconnecting + a "déconnecté" notice. h.sockets[0].close(); expect(h.ws.getConnectionState()).toBe("reconnecting"); expect(chunks.some((c) => decode(c).includes("déconnecté"))).toBe(true); expect(h.hasTimer()).toBe(true); // Fire the reconnect timer → a new socket opens and re-attaches. h.fireTimer(); await vi.waitFor(() => expect(h.sockets.length).toBe(2)); await vi.waitFor(() => expect(h.sockets[1].sent.length).toBeGreaterThan(0)); const reattach = JSON.parse(h.sockets[1].sent[0]); expect(reattach.kind).toBe("terminal.attach"); // lastSeq carries the last output seq seen (5) for precise replay. expect(reattach.payload).toMatchObject({ sessionId: "s1", lastSeq: 5 }); // Server replies with fresh scrollback; the client repaints it + "reconnecté". const scroll = new Uint8Array([82, 69]); h.sockets[1].receive(attachedAck(reattach.id, "s1", 3, scroll)); await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); // The repaint happens after the re-attach ack resolves (a microtask later). await vi.waitFor(() => expect(chunks.some((c) => decode(c).includes("reconnecté"))).toBe(true), ); expect(chunks.some((c) => c.length === 2 && c[0] === 82 && c[1] === 69)).toBe(true); }); it("routes terminal.status exited to onStatus and stops tracking the session", async () => { const h = harness(); const chunks: Uint8Array[] = []; const onStatus = vi.fn(); await attach(h.ws, h.sockets, "s1", (b) => chunks.push(b), onStatus); h.sockets[0].receive({ kind: "terminal.status", payload: { sessionId: "s1", status: "exited", exitCode: 0 }, }); expect(onStatus).toHaveBeenCalledWith("exited", 0); expect(chunks.some((c) => decode(c).includes("session terminée"))).toBe(true); // Session untracked ⇒ a later socket close does not schedule a reconnect. h.sockets[0].close(); expect(h.hasTimer()).toBe(false); }); it("reconnects for a live domain-event subscription even with no terminal (F5)", async () => { const h = harness(); // A live surface subscribes to domain events (no terminal open). h.ws.setDomainEventHandler(() => {}); await h.ws.ensureConnected(); await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); // Drop the socket → must schedule a reconnect (the subscription still needs it). h.sockets[0].close(); expect(h.ws.getConnectionState()).toBe("reconnecting"); expect(h.hasTimer()).toBe(true); h.fireTimer(); await vi.waitFor(() => expect(h.sockets.length).toBe(2)); await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); // Events resume flowing to the persisted handler after reconnect. const events: unknown[] = []; h.ws.setDomainEventHandler((e) => events.push(e)); h.sockets[1].receive({ kind: "event.domain", payload: { type: "agentBusyChanged", agentId: "a1", busy: true } }); expect(events).toHaveLength(1); }); it("dispose() moves to closed and clears any pending reconnect", async () => { const h = harness(); await attach(h.ws, h.sockets, "s1", () => {}); h.sockets[0].close(); expect(h.ws.getConnectionState()).toBe("reconnecting"); h.ws.dispose(); expect(h.ws.getConnectionState()).toBe("closed"); expect(h.hasTimer()).toBe(false); }); it("disconnect() closes and stops reconnecting but stays reusable (F6 sign-out)", async () => { const h = harness(); h.ws.setDomainEventHandler(() => {}); await h.ws.ensureConnected(); await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); // Sign-out: soft teardown. State is closed, the reconnect loop is dropped, and // the detached socket's onclose cannot flip the state back to reconnecting. h.ws.disconnect(); expect(h.ws.getConnectionState()).toBe("closed"); expect(h.hasTimer()).toBe(false); expect(h.ws.needsReconnect()).toBe(false); // Reusable: a later ensureConnected reconnects as a fresh socket (re-pairing). await h.ws.ensureConnected(); await vi.waitFor(() => expect(h.sockets.length).toBe(2)); await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); }); it("calls onReconnectFailed when a reconnect attempt fails (auth probe seam)", async () => { const failed = vi.fn(); let openNextSocket = true; let timerFn: (() => void) | null = null; const sockets: FakeSocket[] = []; const ws = new WsLiveClient({ wsUrl: "wss://host", reconnectBaseMs: 1, onReconnectFailed: failed, socketFactory: () => { const s = new FakeSocket(); sockets.push(s); // First socket connects; the reconnect socket errors (upgrade rejected). if (openNextSocket) queueMicrotask(() => s.onopen?.()); else queueMicrotask(() => s.onerror?.(new Error("upgrade rejected"))); return s; }, setTimeoutImpl: (fn) => { timerFn = fn; return 1; }, clearTimeoutImpl: () => { timerFn = null; }, }); ws.setDomainEventHandler(() => {}); await ws.ensureConnected(); await vi.waitFor(() => expect(ws.getConnectionState()).toBe("connected")); // Drop the socket; the next connect attempt will fail the upgrade. openNextSocket = false; sockets[0].close(); expect(ws.getConnectionState()).toBe("reconnecting"); const fire = timerFn as (() => void) | null; timerFn = null; fire?.(); // The failed reconnect fires the probe hook and schedules another attempt. await vi.waitFor(() => expect(failed).toHaveBeenCalled()); expect(timerFn).not.toBeNull(); }); });