feat(webui): add initial webui with websocket chat flow
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user