Files
IdeA/frontend/src/adapters/http/wsLiveClient.chat.test.ts
Blomios 738e15a09b fix(web): le client WS réattache les sessions chat structurées après reconnexion — #241
needsReconnect() couvre désormais les chatSinks et le cycle de reconnexion
renvoie un chat.attach par session active, un échec isolé n'empêchant pas
les autres abonnements de reprendre. Tests ciblés ajoutés.
2026-08-23 10:24:29 +02:00

199 lines
5.4 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import type { WebSocketLike } from "./wsLiveClient";
import { WsLiveClient } 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 connectedClient(): { client: WsLiveClient; socket: FakeSocket } {
const socket = new FakeSocket();
const client = new WsLiveClient({
wsUrl: "wss://host",
socketFactory: () => {
queueMicrotask(() => socket.onopen?.());
return socket;
},
});
return { client, socket };
}
function attachedAck(id: string, sessionId: string) {
return {
kind: "terminal.attached",
replyTo: id,
payload: {
session: { sessionId, rows: 30, cols: 120 },
scrollback: [],
nextSeq: 0,
status: "running",
gap: false,
},
};
}
async function attachTerminal(
client: WsLiveClient,
sockets: FakeSocket[],
sessionId: string,
): Promise<void> {
const attached = client.attachTerminal({ sessionId, onData: () => {} });
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));
await attached;
}
describe("WsLiveClient structured chat routing", () => {
it("routes chat.output to the per-session chat sink", async () => {
const { client, socket } = connectedClient();
await client.ensureConnected();
const received: unknown[] = [];
client.setChatSink("chat-1", (chunk) => received.push(chunk));
socket.receive({
kind: "chat.output",
payload: {
sessionId: "chat-1",
chunk: { kind: "final", content: "done" },
},
});
expect(received).toEqual([{ kind: "final", content: "done" }]);
client.removeChatSink("chat-1");
socket.receive({
kind: "chat.output",
payload: {
sessionId: "chat-1",
chunk: { kind: "final", content: "ignored" },
},
});
expect(received).toEqual([{ kind: "final", content: "done" }]);
});
it("clears structured chat sinks on disconnect", async () => {
const { client, socket } = connectedClient();
await client.ensureConnected();
const received: unknown[] = [];
client.setChatSink("chat-1", (chunk) => received.push(chunk));
client.disconnect();
expect(client.getConnectionState()).toBe("closed");
socket.receive({
kind: "chat.output",
payload: {
sessionId: "chat-1",
chunk: { kind: "final", content: "dropped" },
},
});
expect(received).toEqual([]);
});
it("re-attaches chat sinks after reconnect while PTY logs keep the socket alive", async () => {
const sockets: FakeSocket[] = [];
let timerFn: (() => void) | null = null;
const client = new WsLiveClient({
wsUrl: "wss://host",
reconnectBaseMs: 1,
socketFactory: () => {
const socket = new FakeSocket();
sockets.push(socket);
queueMicrotask(() => socket.onopen?.());
return socket;
},
setTimeoutImpl: (fn) => {
timerFn = fn;
return 1;
},
clearTimeoutImpl: () => {
timerFn = null;
},
});
await attachTerminal(client, sockets, "pty-1");
const received: unknown[] = [];
client.setChatSink("chat-1", (chunk) => received.push(chunk));
sockets[0].close();
expect(client.getConnectionState()).toBe("reconnecting");
expect(client.needsReconnect()).toBe(true);
const fire = timerFn;
timerFn = null;
fire?.();
await vi.waitFor(() => expect(sockets.length).toBe(2));
await vi.waitFor(() => expect(sockets[1].sent.length).toBeGreaterThan(0));
const terminalAttach = JSON.parse(sockets[1].sent[0]);
expect(terminalAttach.kind).toBe("terminal.attach");
sockets[1].receive(attachedAck(terminalAttach.id, "pty-1"));
await vi.waitFor(() => expect(sockets[1].sent.length).toBeGreaterThan(1));
const chatAttach = JSON.parse(sockets[1].sent[1]);
expect(chatAttach.kind).toBe("chat.attach");
expect(chatAttach.payload).toEqual({ sessionId: "chat-1" });
sockets[1].receive({
kind: "chat.attached",
replyTo: chatAttach.id,
payload: { sessionId: "chat-1", cellKind: "chat" },
});
sockets[1].receive({
kind: "terminal.output",
payload: {
sessionId: "pty-1",
seq: 1,
bytesBase64: bytesToBase64(new Uint8Array([108, 111, 103])),
},
});
sockets[1].receive({
kind: "chat.output",
payload: {
sessionId: "chat-1",
chunk: {
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ticket_read",
text: "reading ticket #241",
},
},
},
});
expect(received).toEqual([
{
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ticket_read",
text: "reading ticket #241",
},
},
]);
});
});