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