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:
144
frontend/src/adapters/http/terminalGateway.test.ts
Normal file
144
frontend/src/adapters/http/terminalGateway.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* F3 — the WS terminal gateway + handle round-trip against the B5 frame contract:
|
||||
* open→attached (empty scrollback), attach→attached (scrollback returned for the
|
||||
* view to repaint, not double-pushed), input/resize emit conforming frames,
|
||||
* detach ≠ close, and `terminal.output` bytes are base64-decoded into the sink.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { HttpTerminalGateway } from "./streamGateways";
|
||||
import { WsLiveClient, type WebSocketLike } 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) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Client whose socket auto-opens on the next microtask. */
|
||||
function client(): { ws: WsLiveClient; sockets: FakeSocket[] } {
|
||||
const sockets: FakeSocket[] = [];
|
||||
const ws = new WsLiveClient({
|
||||
wsUrl: "wss://host",
|
||||
socketFactory: () => {
|
||||
const s = new FakeSocket();
|
||||
sockets.push(s);
|
||||
queueMicrotask(() => s.onopen?.());
|
||||
return s;
|
||||
},
|
||||
});
|
||||
return { ws, sockets };
|
||||
}
|
||||
|
||||
/** Awaits the frame the client just sent, then replies with `reply(id)`. */
|
||||
async function replyToLast(
|
||||
socket: FakeSocket,
|
||||
index: number,
|
||||
reply: (id: string) => unknown,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await vi.waitFor(() => expect(socket.sent.length).toBeGreaterThan(index));
|
||||
const sent = JSON.parse(socket.sent[index]);
|
||||
socket.receive(reply(sent.id));
|
||||
return sent;
|
||||
}
|
||||
|
||||
function attachedAck(id: string, sessionId: string, scrollbackBytes?: Uint8Array) {
|
||||
return {
|
||||
kind: "terminal.attached",
|
||||
replyTo: id,
|
||||
payload: {
|
||||
session: { sessionId, rows: 30, cols: 120 },
|
||||
scrollback: scrollbackBytes
|
||||
? [{ seq: 0, bytesBase64: bytesToBase64(scrollbackBytes) }]
|
||||
: [],
|
||||
nextSeq: scrollbackBytes ? 1 : 0,
|
||||
status: "running",
|
||||
gap: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("HttpTerminalGateway round-trip (B5 frames)", () => {
|
||||
it("open → attached with empty scrollback; output is decoded into the sink", async () => {
|
||||
const { ws, sockets } = client();
|
||||
const gw = new HttpTerminalGateway(ws);
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
const handlePromise = gw.openTerminal({ cwd: "/srv/app", rows: 30, cols: 120 }, (b) =>
|
||||
chunks.push(b),
|
||||
);
|
||||
const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s1"));
|
||||
const handle = await handlePromise;
|
||||
|
||||
// Conforming open frame (no projectId; cwd is server-validated).
|
||||
expect(sent.kind).toBe("terminal.open");
|
||||
expect(sent.payload).toMatchObject({ cwd: "/srv/app", rows: 30, cols: 120 });
|
||||
expect(handle.sessionId).toBe("s1");
|
||||
// No scrollback pushed on a fresh open.
|
||||
expect(chunks).toHaveLength(0);
|
||||
|
||||
// A terminal.output frame is base64-decoded and written to the sink.
|
||||
sockets[0].receive({
|
||||
kind: "terminal.output",
|
||||
payload: { sessionId: "s1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([104, 105])) },
|
||||
});
|
||||
expect(chunks).toEqual([new Uint8Array([104, 105])]);
|
||||
});
|
||||
|
||||
it("attach → attached returns the scrollback for the view (not double-pushed)", async () => {
|
||||
const { ws, sockets } = client();
|
||||
const gw = new HttpTerminalGateway(ws);
|
||||
const chunks: Uint8Array[] = [];
|
||||
const scroll = new Uint8Array([65, 66, 67]);
|
||||
|
||||
const resPromise = gw.reattach("s7", (b) => chunks.push(b));
|
||||
const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s7", scroll));
|
||||
const res = await resPromise;
|
||||
|
||||
expect(sent.kind).toBe("terminal.attach");
|
||||
expect(sent.payload).toMatchObject({ sessionId: "s7" });
|
||||
// Scrollback is returned for TerminalView to repaint…
|
||||
expect(res.scrollback).toEqual(scroll);
|
||||
// …and NOT also pushed through onData (no double paint on the initial attach).
|
||||
expect(chunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("input and resize emit conforming frames; detach ≠ close", async () => {
|
||||
const { ws, sockets } = client();
|
||||
const gw = new HttpTerminalGateway(ws);
|
||||
|
||||
const handlePromise = gw.openTerminal({ cwd: "/srv", rows: 24, cols: 80 }, () => {});
|
||||
await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s1"));
|
||||
const handle = await handlePromise;
|
||||
const base = sockets[0].sent.length;
|
||||
|
||||
await handle.write(new Uint8Array([13]));
|
||||
await handle.resize(40, 140);
|
||||
|
||||
const input = JSON.parse(sockets[0].sent[base]);
|
||||
expect(input.kind).toBe("terminal.input");
|
||||
expect(input.payload).toEqual({ sessionId: "s1", bytesBase64: bytesToBase64(new Uint8Array([13])) });
|
||||
|
||||
const resize = JSON.parse(sockets[0].sent[base + 1]);
|
||||
expect(resize.kind).toBe("terminal.resize");
|
||||
expect(resize.payload).toEqual({ sessionId: "s1", rows: 40, cols: 140 });
|
||||
|
||||
// detach keeps the PTY alive (terminal.detach); close kills it (terminal.close).
|
||||
handle.detach();
|
||||
await vi.waitFor(() =>
|
||||
expect(JSON.parse(sockets[0].sent[base + 2]).kind).toBe("terminal.detach"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user