mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
maintainer edit: Close the session search dialog when the global new-chat shortcut navigates to the blank chat route, and expose the new shortcut through the sidebar button title so the shortcut is discoverable.
1538 lines
55 KiB
TypeScript
1538 lines
55 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { afterEach, 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();
|
|
const toggleThemeSpy = vi.fn();
|
|
const updateUrlSpy = vi.fn();
|
|
const attachSpy = vi.fn();
|
|
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
|
let mockSessions: ChatSummary[] = [];
|
|
const HERO_GREETING_PATTERN =
|
|
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
|
|
|
|
function jsonResponse(body: unknown): Response {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => body,
|
|
} as Response;
|
|
}
|
|
|
|
function baseSettingsPayload() {
|
|
return {
|
|
agent: {
|
|
model: "openai/gpt-4o",
|
|
provider: "auto",
|
|
resolved_provider: "openai",
|
|
has_api_key: true,
|
|
model_preset: "default",
|
|
max_tokens: 8192,
|
|
context_window_tokens: 65536,
|
|
temperature: 0.1,
|
|
reasoning_effort: null,
|
|
timezone: "UTC",
|
|
bot_name: "nanobot",
|
|
bot_icon: "nb",
|
|
tool_hint_max_length: 40,
|
|
},
|
|
model_presets: [{
|
|
name: "default",
|
|
label: "Default",
|
|
active: true,
|
|
is_default: true,
|
|
model: "openai/gpt-4o",
|
|
provider: "auto",
|
|
max_tokens: 8192,
|
|
context_window_tokens: 65536,
|
|
temperature: 0.1,
|
|
reasoning_effort: null,
|
|
}],
|
|
providers: [],
|
|
web_search: {
|
|
provider: "duckduckgo",
|
|
api_key_hint: null,
|
|
base_url: null,
|
|
max_results: 5,
|
|
timeout: 30,
|
|
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
|
|
},
|
|
web: {
|
|
enable: true,
|
|
proxy: null,
|
|
user_agent: null,
|
|
search: { max_results: 5, timeout: 30 },
|
|
fetch: { use_jina_reader: true },
|
|
},
|
|
image_generation: {
|
|
enabled: false,
|
|
provider: "openrouter",
|
|
provider_configured: false,
|
|
model: "openai/gpt-5.4-image-2",
|
|
default_aspect_ratio: "1:1",
|
|
default_image_size: "1K",
|
|
max_images_per_turn: 4,
|
|
save_dir: "generated",
|
|
providers: [],
|
|
},
|
|
runtime: {
|
|
config_path: "/tmp/config.json",
|
|
workspace_path: "/tmp/workspace",
|
|
gateway_host: "127.0.0.1",
|
|
gateway_port: 18790,
|
|
heartbeat: {
|
|
enabled: true,
|
|
interval_s: 1800,
|
|
keep_recent_messages: 8,
|
|
},
|
|
dream: {
|
|
schedule: "every 2h",
|
|
max_batch_size: 20,
|
|
max_iterations: 15,
|
|
annotate_line_ages: true,
|
|
},
|
|
unified_session: false,
|
|
},
|
|
advanced: {
|
|
restrict_to_workspace: false,
|
|
webui_allow_local_service_access: true,
|
|
webui_default_access_mode: "default",
|
|
private_service_protection_enabled: true,
|
|
ssrf_whitelist_count: 0,
|
|
mcp_server_count: 0,
|
|
exec_enabled: true,
|
|
exec_sandbox: null,
|
|
exec_path_append_set: false,
|
|
},
|
|
requires_restart: false,
|
|
};
|
|
}
|
|
|
|
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", async () => {
|
|
const React = await import("react");
|
|
return {
|
|
ThemeProvider: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(React.Fragment, null, children),
|
|
useTheme: () => ({
|
|
theme: "light" as const,
|
|
toggle: toggleThemeSpy,
|
|
}),
|
|
useThemeValue: () => "light" as const,
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/bootstrap", () => ({
|
|
fetchBootstrap: vi.fn().mockResolvedValue({
|
|
token: "tok",
|
|
ws_path: "/",
|
|
expires_in: 300,
|
|
}),
|
|
deriveWsUrl: vi.fn(() => "ws://test"),
|
|
loadSavedSecret: vi.fn(() => ""),
|
|
saveSecret: vi.fn(),
|
|
clearSavedSecret: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/nanobot-client", () => {
|
|
class MockClient {
|
|
status = "idle" as const;
|
|
defaultChatId: string | null = null;
|
|
connect = connectSpy;
|
|
onStatus = () => () => {};
|
|
onRuntimeModelUpdate = () => () => {};
|
|
onError = () => () => {};
|
|
onChat = () => () => {};
|
|
onSessionUpdate = () => () => {};
|
|
onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
|
|
runStatusHandlers.add(handler);
|
|
return () => runStatusHandlers.delete(handler);
|
|
};
|
|
getRunStartedAt = () => null;
|
|
getGoalState = () => undefined;
|
|
sendMessage = vi.fn();
|
|
newChat = vi.fn();
|
|
attach = attachSpy;
|
|
close = vi.fn();
|
|
updateUrl = updateUrlSpy;
|
|
}
|
|
|
|
return { NanobotClient: MockClient };
|
|
});
|
|
|
|
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
|
import App from "@/App";
|
|
|
|
describe("App layout", () => {
|
|
beforeEach(() => {
|
|
mockSessions = [];
|
|
connectSpy.mockClear();
|
|
updateUrlSpy.mockClear();
|
|
refreshSpy.mockReset();
|
|
createChatSpy.mockClear();
|
|
deleteChatSpy.mockReset();
|
|
toggleThemeSpy.mockReset();
|
|
attachSpy.mockReset();
|
|
runStatusHandlers.clear();
|
|
window.history.replaceState(null, "", "/");
|
|
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
|
|
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
|
token: "tok",
|
|
ws_path: "/",
|
|
expires_in: 300,
|
|
});
|
|
vi.mocked(deriveWsUrl).mockReset().mockReturnValue("ws://test");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
}),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
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());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
await waitFor(() =>
|
|
expect(
|
|
within(sidebar).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 this chat?")).toBeInTheDocument(),
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
|
|
|
await waitFor(() =>
|
|
expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a"),
|
|
);
|
|
await waitFor(() =>
|
|
expect(
|
|
within(sidebar).getByRole("button", { name: /^Second chat$/ }),
|
|
).toBeInTheDocument(),
|
|
);
|
|
expect(screen.queryByText("Delete this chat?")).not.toBeInTheDocument();
|
|
expect(document.body.style.pointerEvents).not.toBe("none");
|
|
}, 15_000);
|
|
|
|
it("keeps the mobile session action menu inside the sidebar sheet", async () => {
|
|
mockSessions = [
|
|
{
|
|
key: "websocket:chat-a",
|
|
channel: "websocket",
|
|
chatId: "chat-a",
|
|
createdAt: "2026-04-16T10:00:00Z",
|
|
updatedAt: "2026-04-16T10:00:00Z",
|
|
preview: "Existing chat",
|
|
},
|
|
];
|
|
vi.stubGlobal(
|
|
"matchMedia",
|
|
vi.fn().mockImplementation((query: string) => ({
|
|
matches: !query.includes("1024px"),
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" }));
|
|
|
|
const sheet = await screen.findByRole("dialog");
|
|
const mobileSidebar = within(sheet).getByRole("navigation", {
|
|
name: "Sidebar navigation",
|
|
});
|
|
await waitFor(() =>
|
|
expect(
|
|
within(mobileSidebar).getByRole("button", { name: /^Existing chat$/ }),
|
|
).toBeInTheDocument(),
|
|
);
|
|
|
|
fireEvent.pointerDown(
|
|
within(mobileSidebar).getByLabelText("Chat actions for Existing chat"),
|
|
{ button: 0 },
|
|
);
|
|
|
|
const deleteItem = await within(sheet).findByRole("menuitem", {
|
|
name: "Delete",
|
|
});
|
|
expect(deleteItem).toBeInTheDocument();
|
|
|
|
fireEvent.click(deleteItem);
|
|
await waitFor(() =>
|
|
expect(screen.getByText("Delete this chat?")).toBeInTheDocument(),
|
|
);
|
|
}, 15_000);
|
|
|
|
it("applies persisted sidebar workspace state from the gateway", 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",
|
|
},
|
|
];
|
|
const initialState = {
|
|
schema_version: 1,
|
|
pinned_keys: ["websocket:chat-b"],
|
|
archived_keys: ["websocket:chat-a"],
|
|
title_overrides: { "websocket:chat-b": "Roadmap" },
|
|
tags_by_key: {},
|
|
collapsed_groups: {},
|
|
view: {
|
|
density: "comfortable",
|
|
show_previews: false,
|
|
show_timestamps: false,
|
|
show_archived: false,
|
|
sort: "updated_desc",
|
|
},
|
|
updated_at: null,
|
|
};
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockImplementation(async (url: string | URL | Request) => {
|
|
const href = String(url);
|
|
if (href === "/api/webui/sidebar-state") {
|
|
return { ok: true, json: async () => initialState };
|
|
}
|
|
if (href.startsWith("/api/webui/sidebar-state/update?")) {
|
|
const encoded = new URLSearchParams(href.split("?", 2)[1]).get("state");
|
|
return {
|
|
ok: true,
|
|
json: async () => JSON.parse(encoded ?? "{}"),
|
|
};
|
|
}
|
|
return { ok: false, status: 404 };
|
|
}),
|
|
);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
await waitFor(() =>
|
|
expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(),
|
|
);
|
|
expect(within(sidebar).getByRole("button", { name: /^Roadmap$/ })).toBeInTheDocument();
|
|
expect(within(sidebar).queryByRole("button", { name: /^First chat$/ })).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Show archived" }));
|
|
await waitFor(() =>
|
|
expect(within(sidebar).getByText("Archived")).toBeInTheDocument(),
|
|
);
|
|
expect(within(sidebar).getByRole("button", { name: /^First chat$/ })).toBeInTheDocument();
|
|
const updateUrl = vi.mocked(fetch).mock.calls
|
|
.map(([url]) => String(url))
|
|
.find((url) => url.startsWith("/api/webui/sidebar-state/update?"));
|
|
expect(updateUrl).toBeTruthy();
|
|
const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state");
|
|
expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true);
|
|
|
|
expect(within(sidebar).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("sorts chats by displayed title when A-Z is persisted", async () => {
|
|
mockSessions = [
|
|
{
|
|
key: "websocket:zulu",
|
|
channel: "websocket",
|
|
chatId: "zulu",
|
|
createdAt: "2026-04-16T12:00:00Z",
|
|
updatedAt: "2026-04-16T12:00:00Z",
|
|
title: "Zulu work",
|
|
preview: "later",
|
|
},
|
|
{
|
|
key: "websocket:new",
|
|
channel: "websocket",
|
|
chatId: "new",
|
|
createdAt: "2026-04-15T12:00:00Z",
|
|
updatedAt: "2026-04-15T12:00:00Z",
|
|
preview: "hi nanobot",
|
|
},
|
|
{
|
|
key: "websocket:alpha",
|
|
channel: "websocket",
|
|
chatId: "alpha",
|
|
createdAt: "2026-04-14T12:00:00Z",
|
|
updatedAt: "2026-04-14T12:00:00Z",
|
|
title: "Alpha plan",
|
|
preview: "earlier",
|
|
},
|
|
];
|
|
const initialState = {
|
|
schema_version: 1,
|
|
pinned_keys: [],
|
|
archived_keys: [],
|
|
title_overrides: {},
|
|
tags_by_key: {},
|
|
collapsed_groups: {},
|
|
view: {
|
|
density: "comfortable",
|
|
show_previews: false,
|
|
show_timestamps: false,
|
|
show_archived: false,
|
|
sort: "title_asc",
|
|
},
|
|
updated_at: null,
|
|
};
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockImplementation(async (url: string | URL | Request) => {
|
|
const href = String(url);
|
|
if (href === "/api/webui/sidebar-state") {
|
|
return { ok: true, json: async () => initialState };
|
|
}
|
|
return { ok: false, status: 404 };
|
|
}),
|
|
);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
await waitFor(() =>
|
|
expect(within(sidebar).getByText("Chats")).toBeInTheDocument(),
|
|
);
|
|
const group = within(sidebar).getByText("Chats").closest("section");
|
|
expect(group).toBeTruthy();
|
|
const labels = within(group as HTMLElement)
|
|
.getAllByRole("button")
|
|
.map((button) => button.textContent?.trim())
|
|
.filter(Boolean);
|
|
|
|
expect(labels).toEqual(["Alpha plan", "New chat", "Zulu work"]);
|
|
});
|
|
|
|
it("shows running and completed session indicators in the sidebar", async () => {
|
|
mockSessions = [
|
|
{
|
|
key: "websocket:chat-a",
|
|
channel: "websocket",
|
|
chatId: "chat-a",
|
|
createdAt: "2026-04-16T10:00:00Z",
|
|
updatedAt: "2026-04-16T10:00:00Z",
|
|
preview: "Working chat",
|
|
},
|
|
{
|
|
key: "websocket:chat-b",
|
|
channel: "websocket",
|
|
chatId: "chat-b",
|
|
createdAt: "2026-04-16T11:00:00Z",
|
|
updatedAt: "2026-04-16T11:00:00Z",
|
|
preview: "Quiet chat",
|
|
},
|
|
];
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
await waitFor(() =>
|
|
expect(
|
|
within(sidebar).getByRole("button", { name: /^Working chat$/ }),
|
|
).toBeInTheDocument(),
|
|
);
|
|
|
|
act(() => {
|
|
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
|
|
});
|
|
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument();
|
|
|
|
act(() => {
|
|
for (const handler of runStatusHandlers) handler("chat-a", null);
|
|
});
|
|
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
|
|
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
|
|
});
|
|
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show a completed dot later when the active session finishes", async () => {
|
|
mockSessions = [
|
|
{
|
|
key: "websocket:chat-a",
|
|
channel: "websocket",
|
|
chatId: "chat-a",
|
|
createdAt: "2026-04-16T10:00:00Z",
|
|
updatedAt: "2026-04-16T10:00:00Z",
|
|
preview: "Active work",
|
|
},
|
|
{
|
|
key: "websocket:chat-b",
|
|
channel: "websocket",
|
|
chatId: "chat-b",
|
|
createdAt: "2026-04-16T11:00:00Z",
|
|
updatedAt: "2026-04-16T11:00:00Z",
|
|
preview: "Other chat",
|
|
},
|
|
];
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
await waitFor(() =>
|
|
expect(
|
|
within(sidebar).getByRole("button", { name: /^Active work$/ }),
|
|
).toBeInTheDocument(),
|
|
);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(within(sidebar).getByRole("button", { name: /^Active work$/ }));
|
|
});
|
|
await waitFor(() => expect(document.title).toContain("Active work"));
|
|
|
|
act(() => {
|
|
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
|
|
});
|
|
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument();
|
|
|
|
act(() => {
|
|
for (const handler of runStatusHandlers) handler("chat-a", null);
|
|
});
|
|
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
|
|
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
|
|
});
|
|
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("restores sidebar run indicators after a page reload", async () => {
|
|
mockSessions = [
|
|
{
|
|
key: "websocket:chat-a",
|
|
channel: "websocket",
|
|
chatId: "chat-a",
|
|
createdAt: "2026-04-16T10:00:00Z",
|
|
updatedAt: "2026-04-16T10:00:00Z",
|
|
preview: "Running after reload",
|
|
runStartedAt: 12_345,
|
|
},
|
|
{
|
|
key: "websocket:chat-b",
|
|
channel: "websocket",
|
|
chatId: "chat-b",
|
|
createdAt: "2026-04-16T11:00:00Z",
|
|
updatedAt: "2026-04-16T11:00:00Z",
|
|
preview: "Completed after reload",
|
|
},
|
|
];
|
|
localStorage.setItem(
|
|
"nanobot-webui.sidebar.completed-runs.v1",
|
|
JSON.stringify(["chat-b"]),
|
|
);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
await waitFor(() =>
|
|
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(),
|
|
);
|
|
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
|
|
expect(attachSpy).toHaveBeenCalledWith("chat-a");
|
|
});
|
|
|
|
it("restores the active chat from the URL hash after a page reload", async () => {
|
|
mockSessions = [
|
|
{
|
|
key: "websocket:chat-a",
|
|
channel: "websocket",
|
|
chatId: "chat-a",
|
|
createdAt: "2026-04-16T10:00:00Z",
|
|
updatedAt: "2026-04-16T10:00:00Z",
|
|
preview: "Active after reload",
|
|
},
|
|
{
|
|
key: "websocket:chat-b",
|
|
channel: "websocket",
|
|
chatId: "chat-b",
|
|
createdAt: "2026-04-16T11:00:00Z",
|
|
updatedAt: "2026-04-16T11:00:00Z",
|
|
preview: "Other chat",
|
|
},
|
|
];
|
|
window.history.replaceState(
|
|
null,
|
|
"",
|
|
`/#/chat/${encodeURIComponent("websocket:chat-a")}`,
|
|
);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
await waitFor(() => expect(document.title).toBe("Active after reload · nanobot"));
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
expect(
|
|
within(sidebar).getByRole("button", { name: /^Active after reload$/ }),
|
|
).toBeInTheDocument();
|
|
expect(window.location.hash).toBe(
|
|
`#/chat/${encodeURIComponent("websocket:chat-a")}`,
|
|
);
|
|
});
|
|
|
|
it("opens the settings view from the sidebar footer", async () => {
|
|
mockSessions = [
|
|
{
|
|
key: "websocket:chat-a",
|
|
channel: "websocket",
|
|
chatId: "chat-a",
|
|
createdAt: "2026-04-16T10:00:00Z",
|
|
updatedAt: "2026-04-16T10:00:00Z",
|
|
preview: "Existing chat",
|
|
},
|
|
];
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const href = String(input);
|
|
if (href === "/api/settings/provider-models?provider=openai") {
|
|
return jsonResponse({
|
|
provider: "openai",
|
|
label: "OpenAI",
|
|
status: "available",
|
|
catalog_kind: "official",
|
|
models: [
|
|
{ id: "openai/gpt-4o", owned_by: "openai", context_window: 128000 },
|
|
{ id: "openai/gpt-4o-mini", owned_by: "openai", context_window: 128000 },
|
|
],
|
|
model_count: 2,
|
|
fetched_at: 1,
|
|
});
|
|
}
|
|
if (href.includes("/api/settings")) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
agent: {
|
|
model: "openai/gpt-4o",
|
|
provider: "auto",
|
|
resolved_provider: "openai",
|
|
has_api_key: true,
|
|
model_preset: "default",
|
|
max_tokens: 8192,
|
|
context_window_tokens: 65536,
|
|
temperature: 0.1,
|
|
reasoning_effort: null,
|
|
timezone: "UTC",
|
|
bot_name: "nanobot",
|
|
bot_icon: "nb",
|
|
tool_hint_max_length: 40,
|
|
},
|
|
model_presets: [
|
|
{
|
|
name: "default",
|
|
label: "Default",
|
|
active: true,
|
|
is_default: true,
|
|
model: "openai/gpt-4o",
|
|
provider: "auto",
|
|
max_tokens: 8192,
|
|
context_window_tokens: 65536,
|
|
temperature: 0.1,
|
|
reasoning_effort: null,
|
|
},
|
|
{
|
|
name: "deep",
|
|
label: "deep",
|
|
active: false,
|
|
is_default: false,
|
|
model: "anthropic/claude-opus-4-5",
|
|
provider: "anthropic",
|
|
max_tokens: 8192,
|
|
context_window_tokens: 200000,
|
|
temperature: 0.1,
|
|
reasoning_effort: "high",
|
|
},
|
|
],
|
|
providers: [
|
|
{
|
|
name: "openai",
|
|
label: "OpenAI",
|
|
configured: true,
|
|
api_key_hint: "open••••-key",
|
|
},
|
|
{
|
|
name: "openrouter",
|
|
label: "OpenRouter",
|
|
configured: false,
|
|
api_key_required: true,
|
|
default_api_base: "https://openrouter.ai/api/v1",
|
|
},
|
|
{
|
|
name: "ant_ling",
|
|
label: "Ant Ling",
|
|
configured: false,
|
|
api_key_required: true,
|
|
default_api_base: "https://api.ant-ling.com/v1",
|
|
},
|
|
{
|
|
name: "azure_openai",
|
|
label: "Azure OpenAI",
|
|
configured: false,
|
|
api_key_required: true,
|
|
},
|
|
{
|
|
name: "huggingface",
|
|
label: "Hugging Face",
|
|
configured: false,
|
|
api_key_required: true,
|
|
},
|
|
{
|
|
name: "siliconflow",
|
|
label: "SiliconFlow",
|
|
configured: false,
|
|
api_key_required: true,
|
|
},
|
|
{
|
|
name: "volcengine",
|
|
label: "VolcEngine",
|
|
configured: false,
|
|
api_key_required: true,
|
|
},
|
|
{
|
|
name: "byteplus",
|
|
label: "BytePlus",
|
|
configured: false,
|
|
api_key_required: true,
|
|
},
|
|
{
|
|
name: "qianfan",
|
|
label: "Qianfan",
|
|
configured: false,
|
|
api_key_required: true,
|
|
},
|
|
{
|
|
name: "atomic_chat",
|
|
label: "Atomic Chat",
|
|
configured: false,
|
|
api_key_required: false,
|
|
default_api_base: "http://localhost:1337/v1",
|
|
},
|
|
],
|
|
web_search: {
|
|
provider: "brave",
|
|
api_key_hint: "BSAo••••ew20",
|
|
base_url: null,
|
|
max_results: 5,
|
|
timeout: 30,
|
|
providers: [
|
|
{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" },
|
|
{ name: "brave", label: "Brave Search", credential: "api_key" },
|
|
{ name: "tavily", label: "Tavily", credential: "api_key" },
|
|
],
|
|
},
|
|
web: {
|
|
enable: true,
|
|
proxy: null,
|
|
user_agent: null,
|
|
search: { max_results: 5, timeout: 30 },
|
|
fetch: { use_jina_reader: true },
|
|
},
|
|
image_generation: {
|
|
enabled: false,
|
|
provider: "openrouter",
|
|
provider_configured: true,
|
|
model: "openai/gpt-5.4-image-2",
|
|
default_aspect_ratio: "1:1",
|
|
default_image_size: "1K",
|
|
max_images_per_turn: 4,
|
|
save_dir: "generated",
|
|
providers: [
|
|
{
|
|
name: "openrouter",
|
|
label: "OpenRouter",
|
|
configured: true,
|
|
api_key_hint: "sk-o••••test",
|
|
api_base: "https://openrouter.ai/api/v1",
|
|
default_api_base: "https://openrouter.ai/api/v1",
|
|
},
|
|
{
|
|
name: "gemini",
|
|
label: "Gemini",
|
|
configured: false,
|
|
api_key_hint: null,
|
|
api_base: null,
|
|
default_api_base: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
},
|
|
],
|
|
},
|
|
runtime: {
|
|
config_path: "/tmp/config.json",
|
|
workspace_path: "/tmp/workspace",
|
|
gateway_host: "127.0.0.1",
|
|
gateway_port: 18790,
|
|
heartbeat: {
|
|
enabled: true,
|
|
interval_s: 1800,
|
|
keep_recent_messages: 8,
|
|
},
|
|
dream: {
|
|
schedule: "every 2h",
|
|
},
|
|
unified_session: false,
|
|
},
|
|
advanced: {
|
|
restrict_to_workspace: false,
|
|
webui_allow_local_service_access: true,
|
|
webui_default_access_mode: "default",
|
|
private_service_protection_enabled: true,
|
|
ssrf_whitelist_count: 0,
|
|
mcp_server_count: 0,
|
|
exec_enabled: true,
|
|
exec_sandbox: null,
|
|
exec_path_append_set: false,
|
|
},
|
|
requires_restart: false,
|
|
}),
|
|
};
|
|
}
|
|
return { ok: false, status: 404, json: async () => ({}) };
|
|
}),
|
|
);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
|
|
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
|
|
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
|
|
|
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
|
expect(document.title).toBe("Settings · nanobot");
|
|
expect(screen.getByTestId("overview-nanobot-logo")).toBeInTheDocument();
|
|
expect(screen.getByTestId("overview-logo-openai")).toBeInTheDocument();
|
|
expect(screen.getByTestId("overview-logo-brave")).toBeInTheDocument();
|
|
expect(screen.getByTestId("overview-logo-openrouter")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("overview-logo-nanobot-gateway")).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId("overview-logo-nanobot-workspace")).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument();
|
|
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
|
|
expect(settingsNav.className).toContain("overflow-x-auto");
|
|
expect(settingsNav.className).not.toContain("grid-cols-2");
|
|
expect(within(settingsNav).getByRole("button", { name: "Overview" })).toHaveAttribute(
|
|
"aria-current",
|
|
"page",
|
|
);
|
|
expect(within(settingsNav).getByRole("button", { name: "Models" })).toBeInTheDocument();
|
|
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
|
|
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
|
|
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
|
|
expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument();
|
|
expect(within(settingsNav).getByRole("button", { name: "Security" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
|
|
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
|
|
expect(screen.getByText("Brand logos")).toBeInTheDocument();
|
|
expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument();
|
|
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
|
|
expect(screen.queryByText("AI")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Current configuration")).toBeInTheDocument();
|
|
expect(screen.queryByText("Presets")).not.toBeInTheDocument();
|
|
fireEvent.pointerDown(screen.getByRole("button", { name: "Current configuration" }));
|
|
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
|
|
const modelDialog = await screen.findByRole("dialog", { name: "New model configuration" });
|
|
expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
|
|
fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), {
|
|
target: { value: "Fast writing" },
|
|
});
|
|
fireEvent.change(within(modelDialog).getByPlaceholderText("openai/gpt-4.1"), {
|
|
target: { value: "openai/gpt-4.1-mini" },
|
|
});
|
|
expect(within(modelDialog).getByRole("button", { name: /OpenAI/ })).toBeInTheDocument();
|
|
expect(within(modelDialog).getByRole("button", { name: "Save" })).toBeEnabled();
|
|
fireEvent.click(within(modelDialog).getByRole("button", { name: "Cancel" }));
|
|
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
|
|
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
|
|
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
|
|
const openModelPicker = () => {
|
|
const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ });
|
|
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
|
};
|
|
openModelPicker();
|
|
await screen.findByText("openai/gpt-4o-mini");
|
|
fireEvent.click(screen.getAllByText("openai/gpt-4o-mini")[0]);
|
|
expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
|
|
"text-blue-600",
|
|
);
|
|
const updatedModelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o-mini/ });
|
|
fireEvent.pointerDown(updatedModelButtons[updatedModelButtons.length - 1]);
|
|
await screen.findByText("openai/gpt-4o");
|
|
fireEvent.click(screen.getAllByText("openai/gpt-4o")[0]);
|
|
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
|
|
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
|
|
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
|
|
expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument();
|
|
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
|
|
const clickProviderRow = (label: string) => {
|
|
const providerLabel = screen
|
|
.getAllByText(label)
|
|
.find((element) => element.className.includes("font-semibold"));
|
|
expect(providerLabel).toBeTruthy();
|
|
fireEvent.click(providerLabel!);
|
|
};
|
|
clickProviderRow("OpenAI");
|
|
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
|
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
|
|
target: { value: "unsaved-openai-key" },
|
|
});
|
|
clickProviderRow("OpenRouter");
|
|
clickProviderRow("OpenAI");
|
|
expect(screen.getByText("open••••-key")).toBeInTheDocument();
|
|
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
|
|
clickProviderRow("Ant Ling");
|
|
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
|
|
clickProviderRow("Atomic Chat");
|
|
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();
|
|
|
|
fireEvent.click(within(settingsNav).getByRole("button", { name: "Image" }));
|
|
expect(screen.getByRole("heading", { name: "Image" })).toBeInTheDocument();
|
|
expect(screen.getByRole("switch", { name: "Image generation" })).toBeInTheDocument();
|
|
expect(screen.getByText("Provider status")).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue("openai/gpt-5.4-image-2")).toBeInTheDocument();
|
|
expect(screen.getByText("Save directory")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
|
|
|
fireEvent.click(within(settingsNav).getByRole("button", { name: "Web" }));
|
|
expect(screen.getByText("Search provider")).toBeInTheDocument();
|
|
expect(screen.getByRole("switch", { name: "Jina reader" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument();
|
|
expect(screen.getByTestId("provider-picker-logo-brave")).toBeInTheDocument();
|
|
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
|
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
|
|
target: { value: "unsaved-brave-key" },
|
|
});
|
|
fireEvent.pointerDown(screen.getByRole("button", { name: /Brave Search/ }));
|
|
fireEvent.click(screen.getByRole("menuitem", { name: "Tavily" }));
|
|
fireEvent.pointerDown(screen.getByRole("button", { name: /Tavily/ }));
|
|
fireEvent.click(screen.getByRole("menuitem", { name: "Brave Search" }));
|
|
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
|
|
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(within(settingsNav).getByRole("button", { name: "System" }));
|
|
expect(screen.getByText("Bot name")).toBeInTheDocument();
|
|
expect(screen.queryByText("Tool hint length")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Heartbeat")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Dream")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Unified session")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Default workspace")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
|
fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" }));
|
|
expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument();
|
|
fireEvent.change(screen.getByPlaceholderText("Search timezone"), {
|
|
target: { value: "Shanghai" },
|
|
});
|
|
fireEvent.click(screen.getByRole("menuitem", { name: /Asia\/Shanghai/ }));
|
|
expect(screen.getByRole("button", { name: "Asia/Shanghai" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
|
});
|
|
|
|
it("restores the settings section from the URL hash after a page reload", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
if (String(input) === "/api/settings") {
|
|
return jsonResponse(baseSettingsPayload());
|
|
}
|
|
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
|
}),
|
|
);
|
|
window.history.replaceState(null, "", "/#/settings?section=models");
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
expect(await screen.findByRole("heading", { name: "Models" })).toBeInTheDocument();
|
|
expect(window.location.hash).toBe("#/settings?section=models");
|
|
});
|
|
|
|
it("updates the URL hash when switching settings sections", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
if (String(input) === "/api/settings") {
|
|
return jsonResponse(baseSettingsPayload());
|
|
}
|
|
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
|
}),
|
|
);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
|
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
|
expect(window.location.hash).toBe("#/settings");
|
|
|
|
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
|
|
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
|
|
|
|
expect(await screen.findByRole("heading", { name: "Models" })).toBeInTheDocument();
|
|
expect(window.location.hash).toBe("#/settings?section=models");
|
|
});
|
|
|
|
it("opens Apps from the main sidebar without replacing the sidebar", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const href = String(input);
|
|
if (href === "/api/settings") {
|
|
return jsonResponse(baseSettingsPayload());
|
|
}
|
|
if (href === "/api/settings/cli-apps") {
|
|
return jsonResponse({ apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" });
|
|
}
|
|
if (href === "/api/settings/mcp-presets") {
|
|
return jsonResponse({ presets: [], installed_count: 0 });
|
|
}
|
|
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
|
}),
|
|
);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
|
|
|
|
fireEvent.click(appsButton);
|
|
|
|
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
|
|
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
|
|
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
|
expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute(
|
|
"aria-current",
|
|
"page",
|
|
);
|
|
expect(document.title).toBe("Apps · nanobot");
|
|
});
|
|
|
|
it("returns from settings to the blank start page when no session was active", 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",
|
|
},
|
|
];
|
|
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: "openai",
|
|
resolved_provider: "openai",
|
|
has_api_key: true,
|
|
model_preset: "default",
|
|
max_tokens: 8192,
|
|
context_window_tokens: 65536,
|
|
temperature: 0.1,
|
|
reasoning_effort: null,
|
|
timezone: "UTC",
|
|
bot_name: "nanobot",
|
|
bot_icon: "nb",
|
|
tool_hint_max_length: 40,
|
|
},
|
|
model_presets: [
|
|
{
|
|
name: "default",
|
|
label: "Default",
|
|
active: true,
|
|
is_default: true,
|
|
model: "openai/gpt-4o",
|
|
provider: "openai",
|
|
max_tokens: 8192,
|
|
context_window_tokens: 65536,
|
|
temperature: 0.1,
|
|
reasoning_effort: null,
|
|
},
|
|
],
|
|
providers: [{ name: "openai", label: "OpenAI", configured: true }],
|
|
web_search: {
|
|
provider: "duckduckgo",
|
|
api_key_hint: null,
|
|
base_url: null,
|
|
max_results: 5,
|
|
timeout: 30,
|
|
providers: [
|
|
{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" },
|
|
{ name: "brave", label: "Brave Search", credential: "api_key" },
|
|
],
|
|
},
|
|
web: {
|
|
enable: true,
|
|
proxy: null,
|
|
user_agent: null,
|
|
search: { max_results: 5, timeout: 30 },
|
|
fetch: { use_jina_reader: true },
|
|
},
|
|
image_generation: {
|
|
enabled: false,
|
|
provider: "openrouter",
|
|
provider_configured: false,
|
|
model: "openai/gpt-5.4-image-2",
|
|
default_aspect_ratio: "1:1",
|
|
default_image_size: "1K",
|
|
max_images_per_turn: 4,
|
|
save_dir: "generated",
|
|
providers: [
|
|
{
|
|
name: "openrouter",
|
|
label: "OpenRouter",
|
|
configured: false,
|
|
api_key_hint: null,
|
|
api_base: null,
|
|
default_api_base: "https://openrouter.ai/api/v1",
|
|
},
|
|
],
|
|
},
|
|
runtime: {
|
|
config_path: "/tmp/config.json",
|
|
workspace_path: "/tmp/workspace",
|
|
gateway_host: "127.0.0.1",
|
|
gateway_port: 18790,
|
|
heartbeat: {
|
|
enabled: true,
|
|
interval_s: 1800,
|
|
keep_recent_messages: 8,
|
|
},
|
|
dream: {
|
|
schedule: "every 2h",
|
|
},
|
|
unified_session: false,
|
|
},
|
|
advanced: {
|
|
restrict_to_workspace: false,
|
|
webui_allow_local_service_access: true,
|
|
webui_default_access_mode: "default",
|
|
private_service_protection_enabled: true,
|
|
ssrf_whitelist_count: 0,
|
|
mcp_server_count: 0,
|
|
exec_enabled: true,
|
|
exec_sandbox: null,
|
|
exec_path_append_set: false,
|
|
},
|
|
requires_restart: false,
|
|
}),
|
|
};
|
|
}
|
|
return { ok: false, status: 404, json: async () => ({}) };
|
|
}),
|
|
);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" }));
|
|
await waitFor(() => expect(document.title).toBe("nanobot"));
|
|
|
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
|
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
|
|
|
|
await waitFor(() => expect(document.title).toBe("nanobot"));
|
|
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
|
|
});
|
|
|
|
it("filters sessions in the centered search dialog", async () => {
|
|
mockSessions = [
|
|
{
|
|
key: "websocket:chat-alpha",
|
|
channel: "websocket",
|
|
chatId: "chat-alpha",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
title: "Q2 roadmap",
|
|
preview: "Project planning notes",
|
|
},
|
|
{
|
|
key: "websocket:chat-beta",
|
|
channel: "websocket",
|
|
chatId: "chat-beta",
|
|
createdAt: "2026-04-15T10:00:00Z",
|
|
updatedAt: "2026-04-15T10:00:00Z",
|
|
preview: "Travel ideas",
|
|
},
|
|
];
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument();
|
|
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
|
|
const newChatButton = within(sidebar).getByRole("button", { name: "New chat" });
|
|
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
|
|
expect(
|
|
newChatButton.compareDocumentPosition(searchButton) &
|
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(searchButton);
|
|
const dialog = await screen.findByRole("dialog", { name: "Search" });
|
|
expect(dialog).toHaveClass("origin-center");
|
|
expect(dialog.className).not.toContain("translate-x");
|
|
expect(dialog.className).not.toContain("translate-y");
|
|
expect(dialog.querySelector("kbd")).toBeNull();
|
|
expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
|
|
expect(within(dialog).getByText("Travel ideas")).toBeInTheDocument();
|
|
expect(within(dialog).queryByText("websocket")).not.toBeInTheDocument();
|
|
expect(within(dialog).queryByText("#1")).not.toBeInTheDocument();
|
|
|
|
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search" }), {
|
|
target: { value: "planning" },
|
|
});
|
|
|
|
expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
|
|
expect(within(dialog).queryByText("Travel ideas")).not.toBeInTheDocument();
|
|
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
|
|
|
|
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search" }), {
|
|
target: { value: "road q2" },
|
|
});
|
|
|
|
expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
|
|
expect(within(dialog).queryByText("Travel ideas")).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(within(dialog).getByRole("button", { name: /Q2 roadmap/ }));
|
|
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument(),
|
|
);
|
|
});
|
|
|
|
it("opens search from the keyboard shortcut", async () => {
|
|
mockSessions = [
|
|
{
|
|
key: "websocket:chat-a",
|
|
channel: "websocket",
|
|
chatId: "chat-a",
|
|
createdAt: "2026-04-16T10:00:00Z",
|
|
updatedAt: "2026-04-16T10:00:00Z",
|
|
preview: "Existing chat",
|
|
},
|
|
];
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
fireEvent.keyDown(window, { key: "k", metaKey: true });
|
|
|
|
const dialog = await screen.findByRole("dialog", { name: "Search" });
|
|
expect(within(dialog).queryByText("Global actions")).not.toBeInTheDocument();
|
|
expect(within(dialog).getByText("Existing chat")).toBeInTheDocument();
|
|
|
|
const textbox = within(dialog).getByRole("textbox", { name: "Search" });
|
|
fireEvent.change(textbox, { target: { value: "missing" } });
|
|
expect(within(dialog).queryByText("Existing chat")).not.toBeInTheDocument();
|
|
|
|
fireEvent.change(textbox, { target: { value: "existing" } });
|
|
expect(within(dialog).getByText("Existing chat")).toBeInTheDocument();
|
|
|
|
fireEvent.keyDown(textbox, { key: "Enter" });
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument(),
|
|
);
|
|
expect(createChatSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["Command", { metaKey: true }],
|
|
["Control", { ctrlKey: true }],
|
|
])("starts a new chat from the %s keyboard shortcut", async (_label, modifier) => {
|
|
mockSessions = [
|
|
{
|
|
key: "websocket:chat-a",
|
|
channel: "websocket",
|
|
chatId: "chat-a",
|
|
createdAt: "2026-04-16T10:00:00Z",
|
|
updatedAt: "2026-04-16T10:00:00Z",
|
|
preview: "Existing chat",
|
|
},
|
|
];
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
fireEvent.keyDown(window, { key: "O", shiftKey: true, ...modifier });
|
|
|
|
expect(window.location.hash).toBe("#/new");
|
|
});
|
|
|
|
it("closes search when starting a new chat from the keyboard shortcut", async () => {
|
|
mockSessions = [
|
|
{
|
|
key: "websocket:chat-a",
|
|
channel: "websocket",
|
|
chatId: "chat-a",
|
|
createdAt: "2026-04-16T10:00:00Z",
|
|
updatedAt: "2026-04-16T10:00:00Z",
|
|
preview: "Existing chat",
|
|
},
|
|
];
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
fireEvent.keyDown(window, { key: "k", metaKey: true });
|
|
expect(await screen.findByRole("dialog", { name: "Search" })).toBeInTheDocument();
|
|
|
|
fireEvent.keyDown(window, { key: "O", shiftKey: true, metaKey: true });
|
|
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument(),
|
|
);
|
|
expect(window.location.hash).toBe("#/new");
|
|
});
|
|
|
|
it("exposes the new chat keyboard shortcut in the sidebar title", async () => {
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
|
|
expect(within(sidebar).getByRole("button", { name: "New chat" })).toHaveAttribute(
|
|
"title",
|
|
"New chat (Cmd/Ctrl+Shift+O)",
|
|
);
|
|
});
|
|
|
|
it("keeps large sidebars light while search still covers every chat", async () => {
|
|
mockSessions = Array.from({ length: 170 }, (_, index) => {
|
|
const chatId = `chat-${index}`;
|
|
return {
|
|
key: `websocket:${chatId}`,
|
|
channel: "websocket" as const,
|
|
chatId,
|
|
createdAt: new Date(Date.UTC(2026, 3, 16, 12, 0 - index)).toISOString(),
|
|
updatedAt: new Date(Date.UTC(2026, 3, 16, 12, 0 - index)).toISOString(),
|
|
title: index === 169 ? "Hidden target" : `Bulk chat ${index}`,
|
|
preview: "",
|
|
};
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
await waitFor(() =>
|
|
expect(within(sidebar).getByRole("button", { name: "Bulk chat 0" })).toBeInTheDocument(),
|
|
);
|
|
expect(within(sidebar).queryByText("Hidden target")).not.toBeInTheDocument();
|
|
expect(within(sidebar).getByRole("button", { name: "Show 10 more" })).toBeInTheDocument();
|
|
|
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Search" }));
|
|
const dialog = await screen.findByRole("dialog", { name: "Search" });
|
|
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search" }), {
|
|
target: { value: "hidden" },
|
|
});
|
|
expect(within(dialog).getByText("Hidden target")).toBeInTheDocument();
|
|
});
|
|
|
|
it("opens a blank start page without creating an empty 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: "Existing chat",
|
|
},
|
|
];
|
|
|
|
const matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
matches: query.includes("1024px"),
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}));
|
|
vi.stubGlobal("matchMedia", matchMedia);
|
|
|
|
const { container } = render(<App />);
|
|
|
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Toggle theme from header" }));
|
|
expect(toggleThemeSpy).toHaveBeenCalledTimes(1);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
|
|
const sidebarAside = container.querySelector("aside.lg\\:block") as HTMLElement;
|
|
await waitFor(() => expect(sidebarAside.style.width).toBe("56px"));
|
|
|
|
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
|
|
const rail = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
expect(within(rail).getByRole("button", { name: "New chat" })).toBeInTheDocument();
|
|
expect(within(rail).getByRole("button", { name: "Search" })).toBeInTheDocument();
|
|
expect(within(rail).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
|
|
expect(within(rail).queryByText("Existing chat")).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(within(rail).getByRole("button", { name: "Toggle sidebar" }));
|
|
await waitFor(() => expect(sidebarAside.style.width).toBe("272px"));
|
|
|
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" }));
|
|
expect(createChatSpy).not.toHaveBeenCalled();
|
|
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
|
|
expect(within(sidebar).getByRole("button", { name: "Settings" })).toBeInTheDocument();
|
|
|
|
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
|
|
});
|
|
|
|
it("refreshes the bootstrap token before REST settings auth expires", async () => {
|
|
vi.useFakeTimers();
|
|
vi.mocked(fetchBootstrap)
|
|
.mockResolvedValueOnce({
|
|
token: "tok-1",
|
|
ws_path: "/",
|
|
expires_in: 30,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
token: "tok-2",
|
|
ws_path: "/",
|
|
expires_in: 300,
|
|
});
|
|
vi.mocked(deriveWsUrl).mockImplementation(
|
|
(_wsPath: string, token: string) => `ws://test?token=${token}`,
|
|
);
|
|
|
|
const { unmount } = render(<App />);
|
|
await act(async () => {});
|
|
|
|
expect(connectSpy).toHaveBeenCalled();
|
|
expect(fetchBootstrap).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
});
|
|
|
|
expect(fetchBootstrap).toHaveBeenCalledTimes(2);
|
|
expect(updateUrlSpy).toHaveBeenCalledWith("ws://test?token=tok-2");
|
|
unmount();
|
|
});
|
|
});
|