feat(frontend): xterm.js sur WebSocket avec reconnexion et scrollback (#13)
Lot F3 du chantier server/client mode : le client web branche xterm.js sur le terminal distant via WebSocket, en face de l'endpoint PTY B5. - wsLiveClient.ts : transport WebSocket du terminal avec reconnexion. - streamGateways.ts : gateway terminal (open/attach/data/resize/close). - frames.ts : frames PTY alignées sur le contrat serveur B5. - Tests : terminalGateway.test.ts, wsLiveClientReconnect.test.ts. Validé : frontend 743 tests verts (F3 ciblé 12), cohérence des frames B5↔F3 confirmée, desktop non régressé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
170
frontend/src/adapters/http/wsLiveClientReconnect.test.ts
Normal file
170
frontend/src/adapters/http/wsLiveClientReconnect.test.ts
Normal file
@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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<void> {
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user