feat(webui): add initial webui with websocket chat flow
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NanobotClient } from "@/lib/nanobot-client";
|
||||
|
||||
/**
|
||||
* Minimal fake WebSocket implementing the subset NanobotClient touches.
|
||||
* Every instance is retrievable via ``FakeSocket.instances`` so tests can
|
||||
* drive open/close/message lifecycles deterministically.
|
||||
*/
|
||||
class FakeSocket {
|
||||
static instances: FakeSocket[] = [];
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
url: string;
|
||||
readyState = FakeSocket.CONNECTING;
|
||||
sent: string[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = FakeSocket.CLOSED;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
fakeOpen() {
|
||||
this.readyState = FakeSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
fakeMessage(payload: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function lastSocket(): FakeSocket {
|
||||
const s = FakeSocket.instances.at(-1);
|
||||
if (!s) throw new Error("no socket created yet");
|
||||
return s;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeSocket.instances = [];
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("NanobotClient", () => {
|
||||
it("routes events to the matching chat handler", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const handler = vi.fn();
|
||||
client.onChat("chat-a", handler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({ event: "message", chat_id: "chat-a", text: "hi" });
|
||||
lastSocket().fakeMessage({ event: "message", chat_id: "chat-b", text: "no" });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0]).toMatchObject({
|
||||
event: "message",
|
||||
chat_id: "chat-a",
|
||||
text: "hi",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves newChat() via the server-assigned chat_id", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const promise = client.newChat(1_000);
|
||||
expect(lastSocket().sent).toContain(JSON.stringify({ type: "new_chat" }));
|
||||
lastSocket().fakeMessage({ event: "attached", chat_id: "fresh-id" });
|
||||
await expect(promise).resolves.toBe("fresh-id");
|
||||
});
|
||||
|
||||
it("queues sends while connecting and flushes on open", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
client.sendMessage("chat-x", "hello");
|
||||
expect(lastSocket().sent).toEqual([]);
|
||||
lastSocket().fakeOpen();
|
||||
// Attach is sent first because sendMessage adds to knownChats, which
|
||||
// handleOpen re-attaches; then the queued message follows.
|
||||
expect(lastSocket().sent).toContain(
|
||||
JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-attaches known chats after a reconnect", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: true,
|
||||
maxBackoffMs: 10,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.onChat("chat-z", () => {});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
expect(lastSocket().sent).toContain(
|
||||
JSON.stringify({ type: "attach", chat_id: "chat-z" }),
|
||||
);
|
||||
// Drop the socket.
|
||||
lastSocket().close();
|
||||
// Advance the backoff timer.
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
const reconnected = lastSocket();
|
||||
expect(reconnected).not.toBe(FakeSocket.instances[0]);
|
||||
reconnected.fakeOpen();
|
||||
expect(reconnected.sent).toContain(
|
||||
JSON.stringify({ type: "attach", chat_id: "chat-z" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports status transitions through onStatus", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const seen: string[] = [];
|
||||
client.onStatus((s) => seen.push(s));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().close();
|
||||
expect(seen).toEqual(["idle", "connecting", "open", "closed"]);
|
||||
});
|
||||
|
||||
it("does not schedule a reconnect when close() is called explicitly", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: true,
|
||||
maxBackoffMs: 10,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const seen: string[] = [];
|
||||
client.onStatus((s) => seen.push(s));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.close();
|
||||
// Advance past any possible backoff window to prove no reconnect was scheduled.
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(FakeSocket.instances).toHaveLength(1);
|
||||
// "reconnecting" must never appear after an intentional close.
|
||||
expect(seen).not.toContain("reconnecting");
|
||||
expect(seen.at(-1)).toBe("closed");
|
||||
});
|
||||
|
||||
it("surfaces 'reconnecting' only on an unexpected drop", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: true,
|
||||
maxBackoffMs: 5,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const seen: string[] = [];
|
||||
client.onStatus((s) => seen.push(s));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
// Simulate the remote side hanging up (no client.close() call).
|
||||
lastSocket().close();
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(seen).toContain("reconnecting");
|
||||
expect(FakeSocket.instances.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user