feat(webui): add initial webui with websocket chat flow
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { deleteSession, fetchSessionMessages } from "@/lib/api";
|
||||
|
||||
describe("webui API helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ deleted: true, key: "websocket:chat-1", messages: [] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys when fetching session history", async () => {
|
||||
await fetchSessionMessages("tok", "websocket:chat-1");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/messages",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys when deleting a session", async () => {
|
||||
await deleteSession("tok", "websocket:chat-1");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/delete",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
const connectSpy = vi.fn();
|
||||
const refreshSpy = vi.fn();
|
||||
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
|
||||
const deleteChatSpy = vi.fn();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
|
||||
vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
const React = await import("react");
|
||||
const actual = await importOriginal<typeof import("@/hooks/useSessions")>();
|
||||
return {
|
||||
...actual,
|
||||
useSessions: () => {
|
||||
const [sessions, setSessions] = React.useState(mockSessions);
|
||||
return {
|
||||
sessions,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: refreshSpy,
|
||||
createChat: createChatSpy,
|
||||
deleteChat: async (key: string) => {
|
||||
await deleteChatSpy(key);
|
||||
setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/useTheme", () => ({
|
||||
useTheme: () => ({
|
||||
theme: "light" as const,
|
||||
toggle: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/bootstrap", () => ({
|
||||
fetchBootstrap: vi.fn().mockResolvedValue({
|
||||
token: "tok",
|
||||
ws_path: "/",
|
||||
expires_in: 300,
|
||||
}),
|
||||
deriveWsUrl: vi.fn(() => "ws://test"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/nanobot-client", () => {
|
||||
class MockClient {
|
||||
status = "idle" as const;
|
||||
defaultChatId: string | null = null;
|
||||
connect = connectSpy;
|
||||
onStatus = () => () => {};
|
||||
onChat = () => () => {};
|
||||
sendMessage = vi.fn();
|
||||
newChat = vi.fn();
|
||||
attach = vi.fn();
|
||||
close = vi.fn();
|
||||
updateUrl = vi.fn();
|
||||
}
|
||||
|
||||
return { NanobotClient: MockClient };
|
||||
});
|
||||
|
||||
import App from "@/App";
|
||||
|
||||
describe("App layout", () => {
|
||||
beforeEach(() => {
|
||||
mockSessions = [];
|
||||
connectSpy.mockClear();
|
||||
refreshSpy.mockReset();
|
||||
createChatSpy.mockClear();
|
||||
deleteChatSpy.mockReset();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps sidebar layout out of the main thread width contract", async () => {
|
||||
const { container } = render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
|
||||
const main = container.querySelector("main");
|
||||
expect(main).toBeInTheDocument();
|
||||
expect(main).not.toHaveAttribute("style");
|
||||
|
||||
const asideClassNames = Array.from(container.querySelectorAll("aside")).map(
|
||||
(el) => el.className,
|
||||
);
|
||||
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
|
||||
});
|
||||
|
||||
it("switches to the next session when deleting the active chat", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "First chat",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-b",
|
||||
channel: "websocket",
|
||||
chatId: "chat-b",
|
||||
createdAt: "2026-04-16T11:00:00Z",
|
||||
updatedAt: "2026-04-16T11:00:00Z",
|
||||
preview: "Second chat",
|
||||
},
|
||||
];
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), {
|
||||
button: 0,
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Delete “First chat”?')).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a"),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^Second chat$/ }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
|
||||
expect(document.body.style.pointerEvents).not.toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
it("renders user messages as right-aligned pills", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "hello",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const { container } = render(<MessageBubble message={message} />);
|
||||
const row = container.firstElementChild;
|
||||
const pill = screen.getByText("hello");
|
||||
|
||||
expect(row).toHaveClass("ml-auto", "flex");
|
||||
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
|
||||
});
|
||||
|
||||
it("renders trace messages as collapsible tool groups", () => {
|
||||
const message: UIMessage = {
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: 'search "hk weather"',
|
||||
traces: ['weather("get")', 'search "hk weather"'],
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} />);
|
||||
const toggle = screen.getByRole("button", { name: /used 2 tools/i });
|
||||
|
||||
expect(screen.getByText('weather("get")')).toBeInTheDocument();
|
||||
expect(screen.getByText('search "hk weather"')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.queryByText('weather("get")')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// happy-dom doesn't ship with ``crypto.randomUUID``; shim a tiny v4-ish helper.
|
||||
if (!("randomUUID" in globalThis.crypto)) {
|
||||
Object.defineProperty(globalThis.crypto, "randomUUID", {
|
||||
value: () =>
|
||||
"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
}),
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
|
||||
describe("ThreadComposer", () => {
|
||||
it("renders a readonly hero model composer when provided", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
modelLabel="claude-opus-4-5"
|
||||
placeholder="What's on your mind?"
|
||||
variant="hero"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument();
|
||||
const input = screen.getByPlaceholderText("What's on your mind?");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.className).toContain("min-h-[96px]");
|
||||
expect(input.parentElement?.className).toContain("max-w-[40rem]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
function makeClient() {
|
||||
return {
|
||||
status: "open" as const,
|
||||
defaultChatId: null as string | null,
|
||||
onStatus: () => () => {},
|
||||
onChat: () => () => {},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
close: vi.fn(),
|
||||
updateUrl: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function wrap(client: ReturnType<typeof makeClient>, children: ReactNode) {
|
||||
return (
|
||||
<ClientProvider
|
||||
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
||||
token="tok"
|
||||
>
|
||||
{children}
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function session(chatId: string) {
|
||||
return {
|
||||
key: `websocket:${chatId}`,
|
||||
channel: "websocket" as const,
|
||||
chatId,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
preview: "",
|
||||
};
|
||||
}
|
||||
|
||||
function httpJson(body: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ThreadShell", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores in-memory messages when switching away and back to a session", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
||||
|
||||
const { rerender } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "persist me across tabs" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"persist me across tabs",
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-b")}
|
||||
title="Chat chat-b"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the old thread when the active session is removed", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
||||
|
||||
const { rerender } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "delete me cleanly" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"delete me cleanly",
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("delete me cleanly")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={null}
|
||||
title="nanobot"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not leak the previous thread when opening a brand-new chat", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-new");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-a/messages")) {
|
||||
return httpJson({
|
||||
key: "websocket:chat-a",
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
messages: [
|
||||
{ role: "user", content: "old question" },
|
||||
{ role: "assistant", content: "old answer" },
|
||||
],
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-new")}
|
||||
title="Chat chat-new"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(),
|
||||
);
|
||||
const input = screen.getByPlaceholderText("What's on your mind?");
|
||||
expect(input.className).toContain("min-h-[96px]");
|
||||
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the previous thread immediately while the next session loads", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-b");
|
||||
let resolveChatB:
|
||||
| ((value: { ok: boolean; status: number; json: () => Promise<unknown> }) => void)
|
||||
| null = null;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-a/messages")) {
|
||||
return Promise.resolve(
|
||||
httpJson({
|
||||
key: "websocket:chat-a",
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
messages: [{ role: "assistant", content: "from chat a" }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.includes("websocket%3Achat-b/messages")) {
|
||||
return new Promise((resolve) => {
|
||||
resolveChatB = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("from chat a")).toBeInTheDocument());
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-b")}
|
||||
title="Chat chat-b"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByText("from chat a")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Loading conversation…")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolveChatB?.(
|
||||
httpJson({
|
||||
key: "websocket:chat-b",
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
messages: [{ role: "assistant", content: "from chat b" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("from chat b")).toBeInTheDocument());
|
||||
expect(screen.queryByText("from chat a")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||
import type { InboundEvent } from "@/lib/types";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
function fakeClient() {
|
||||
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
|
||||
return {
|
||||
client: {
|
||||
status: "open" as const,
|
||||
defaultChatId: null as string | null,
|
||||
onStatus: () => () => {},
|
||||
onChat(chatId: string, h: (ev: InboundEvent) => void) {
|
||||
let set = handlers.get(chatId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
handlers.set(chatId, set);
|
||||
}
|
||||
set.add(h);
|
||||
return () => set!.delete(h);
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
close: vi.fn(),
|
||||
updateUrl: vi.fn(),
|
||||
},
|
||||
emit(chatId: string, ev: InboundEvent) {
|
||||
const set = handlers.get(chatId);
|
||||
set?.forEach((h) => h(ev));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrap(client: ReturnType<typeof fakeClient>["client"]) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ClientProvider
|
||||
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
||||
token="tok"
|
||||
>
|
||||
{children}
|
||||
</ClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("useNanobotStream", () => {
|
||||
it("collapses consecutive tool_hint frames into one trace row", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-t", []), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-t", {
|
||||
event: "message",
|
||||
chat_id: "chat-t",
|
||||
text: 'weather("get")',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-t", {
|
||||
event: "message",
|
||||
chat_id: "chat-t",
|
||||
text: 'search "hk weather"',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].kind).toBe("trace");
|
||||
expect(result.current.messages[0].role).toBe("tool");
|
||||
expect(result.current.messages[0].traces).toEqual([
|
||||
'weather("get")',
|
||||
'search "hk weather"',
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-t", {
|
||||
event: "message",
|
||||
chat_id: "chat-t",
|
||||
text: "## Summary",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[1].role).toBe("assistant");
|
||||
expect(result.current.messages[1].kind).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useSessions } from "@/hooks/useSessions";
|
||||
import * as api from "@/lib/api";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
listSessions: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
fetchSessionMessages: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function fakeClient() {
|
||||
return {
|
||||
status: "open" as const,
|
||||
defaultChatId: null as string | null,
|
||||
onStatus: () => () => {},
|
||||
onChat: () => () => {},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
close: vi.fn(),
|
||||
updateUrl: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function wrap(client: ReturnType<typeof fakeClient>) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ClientProvider
|
||||
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
||||
token="tok"
|
||||
>
|
||||
{children}
|
||||
</ClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("useSessions", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.listSessions).mockReset();
|
||||
vi.mocked(api.deleteSession).mockReset();
|
||||
vi.mocked(api.fetchSessionMessages).mockReset();
|
||||
});
|
||||
|
||||
it("removes a session from the local list after delete succeeds", async () => {
|
||||
vi.mocked(api.listSessions).mockResolvedValue([
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "Alpha",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-b",
|
||||
channel: "websocket",
|
||||
chatId: "chat-b",
|
||||
createdAt: "2026-04-16T11:00:00Z",
|
||||
updatedAt: "2026-04-16T11:00:00Z",
|
||||
preview: "Beta",
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.deleteSession).mockResolvedValue(true);
|
||||
|
||||
const { result } = renderHook(() => useSessions(), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteChat("websocket:chat-a");
|
||||
});
|
||||
|
||||
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a");
|
||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
||||
});
|
||||
|
||||
it("keeps the session in the list when delete fails", async () => {
|
||||
vi.mocked(api.listSessions).mockResolvedValue([
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "Alpha",
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.deleteSession).mockRejectedValue(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(() => useSessions(), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.deleteChat("websocket:chat-a");
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-a"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user