nanobot/webui/src/tests/app-layout.test.tsx
moran 61ae869610 feat(providers): add APIFree support
Add APIFree as a built-in OpenAI-compatible provider. APIFree offers
agent-optimised models such as skywork-ai/skyclaw-v1 through an
OpenAI-compatible API at https://api.apifree.ai/agent/v1.

Changes:
- Register apifree provider in the provider registry
- Add config schema field
- Add documentation with configuration example
- Add provider tests, websocket channel tests, and webui tests
- Add provider icon in settings UI
2026-05-20 12:33:03 +08:00

1087 lines
38 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[] = [];
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();
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);
fireEvent.pointerDown(within(sidebar).getByRole("button", { name: "View" }), {
button: 0,
ctrlKey: false,
});
fireEvent.click(await screen.findByText("Compact list"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.density).toBe("compact");
});
fireEvent.click(screen.getByText("Title A-Z"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.sort).toBe("title_asc");
});
});
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("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("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) => {
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,
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: "apifree",
label: "APIFree",
configured: false,
api_key_required: true,
default_api_base: "https://api.apifree.ai/agent/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",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
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: "Settings" }));
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(document.title).toBe("Settings · nanobot");
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).getByRole("button", { name: "Providers" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
expect(screen.getByText("AI")).toBeInTheDocument();
const modelInput = screen.getByDisplayValue("openai/gpt-4o");
expect(modelInput).toBeInTheDocument();
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o-mini" } });
expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
"text-blue-600",
);
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o" } });
fireEvent.click(within(settingsNav).getByRole("button", { name: "Providers" }));
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
expect(screen.getByText("APIFree")).toBeInTheDocument();
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
fireEvent.click(screen.getByText("OpenAI"));
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
target: { value: "unsaved-openai-key" },
});
fireEvent.click(screen.getByText("OpenRouter"));
fireEvent.click(screen.getByText("OpenAI"));
expect(screen.getByText("open••••-key")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Ant Ling"));
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
fireEvent.click(screen.getByText("APIFree"));
expect(screen.getByDisplayValue("https://api.apifree.ai/agent/v1")).toBeInTheDocument();
fireEvent.click(screen.getByText("Atomic Chat"));
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Image" }));
expect(screen.getByRole("heading", { name: "Image" })).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("button", { name: /Brave Search/ })).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: "Runtime" }));
expect(screen.getByText("Bot name")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
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",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
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("What can I do for you?")).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(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 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 desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(desktopAside.style.width).toBe("0px"));
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" }));
await waitFor(() => expect(desktopAside.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("What can I do for you?")).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();
});
});