nanobot/webui/src/tests/app-layout.test.tsx

190 lines
5.3 KiB
TypeScript

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 = () => () => {};
onError = () => () => {};
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");
}, 15_000);
it("opens the Cursor-style settings view from the sidebar", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input).includes("/api/settings")) {
return {
ok: true,
status: 200,
json: async () => ({
agent: {
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
},
providers: [
{ name: "auto", label: "Auto" },
{ name: "openai", label: "OpenAI" },
],
runtime: {
config_path: "/tmp/config.json",
},
requires_restart: false,
}),
};
}
return { ok: false, status: 404, json: async () => ({}) };
}),
);
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
expect(screen.getByText("AI")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
});
});