* feat(webui): refine output timeline and composer queue * feat(webui): add provider model picker * fix(webui): polish model settings and heartbeat checks * chore: keep heartbeat changes out of webui pr * refactor(webui): isolate settings routes * fix(providers): align minimax anthropic test * fix(providers): keep minimax anthropic base sdk-compatible * fix(providers): normalize anthropic base urls
505 lines
15 KiB
TypeScript
505 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
createModelConfiguration,
|
|
deleteSession,
|
|
fetchCliApps,
|
|
fetchMcpPresets,
|
|
fetchProviderModels,
|
|
fetchSidebarState,
|
|
fetchWebuiThread,
|
|
fetchWorkspaces,
|
|
importMcpConfig,
|
|
listSessions,
|
|
listSlashCommands,
|
|
loginProviderOAuth,
|
|
logoutProviderOAuth,
|
|
runCliAppAction,
|
|
runMcpPresetAction,
|
|
saveCustomMcpServer,
|
|
updateSidebarState,
|
|
updateImageGenerationSettings,
|
|
updateModelConfiguration,
|
|
updateMcpServerTools,
|
|
updateNetworkSafetySettings,
|
|
updateProviderSettings,
|
|
updateSettings,
|
|
updateWebSearchSettings,
|
|
} from "@/lib/api";
|
|
|
|
describe("webui API helpers", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ deleted: true, key: "websocket:chat-1", messages: [] }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("percent-encodes websocket keys when fetching webui-thread snapshot", async () => {
|
|
await fetchWebuiThread("tok", "websocket:chat-1");
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/sessions/websocket%3Achat-1/webui-thread",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
credentials: "same-origin",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("percent-encodes websocket keys when deleting a session", async () => {
|
|
await deleteSession("tok", "websocket:chat-1");
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/sessions/websocket%3Achat-1/delete",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("serializes settings updates as a narrow query string", async () => {
|
|
await updateSettings("tok", {
|
|
modelPreset: "default",
|
|
model: "openrouter/test",
|
|
provider: "openrouter",
|
|
contextWindowTokens: 262144,
|
|
timezone: "Asia/Shanghai",
|
|
botName: "nanobot",
|
|
botIcon: "nb",
|
|
toolHintMaxLength: 120,
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&bot_name=nanobot&bot_icon=nb&tool_hint_max_length=120",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("serializes model configuration creation", async () => {
|
|
await createModelConfiguration("tok", {
|
|
label: "Fast writing",
|
|
provider: "openai",
|
|
model: "openai/gpt-4.1-mini",
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("serializes model configuration updates", async () => {
|
|
await updateModelConfiguration("tok", {
|
|
name: "codex",
|
|
label: "Codex",
|
|
provider: "openai_codex",
|
|
model: "openai-codex/gpt-5.5",
|
|
contextWindowTokens: 65536,
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&context_window_tokens=65536",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("reports HTML API fallbacks as gateway mismatch errors", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ "content-type": "text/html; charset=utf-8" }),
|
|
text: async () => "<!doctype html><html></html>",
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
updateModelConfiguration("tok", {
|
|
name: "codex",
|
|
model: "openai-codex/gpt-5.5",
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 200,
|
|
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
|
|
});
|
|
});
|
|
|
|
it("surfaces API error response bodies", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
text: async () => "npm error ENOTEMPTY",
|
|
}),
|
|
);
|
|
|
|
await expect(runCliAppAction("tok", "install", "hyperframes")).rejects.toMatchObject({
|
|
status: 500,
|
|
message: "npm error ENOTEMPTY",
|
|
});
|
|
});
|
|
|
|
it("serializes provider settings updates without returning secrets", async () => {
|
|
await updateProviderSettings("tok", {
|
|
provider: "openrouter",
|
|
apiKey: "sk-or-test",
|
|
apiBase: "https://openrouter.ai/api/v1",
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/provider/update?provider=openrouter&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("fetches provider model lists", async () => {
|
|
await fetchProviderModels("tok", "deepseek");
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/provider-models?provider=deepseek",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("serializes provider OAuth login and logout actions", async () => {
|
|
await loginProviderOAuth("tok", "openai_codex");
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/provider/oauth-login?provider=openai_codex",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
|
|
await logoutProviderOAuth("tok", "openai_codex");
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/provider/oauth-logout?provider=openai_codex",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("serializes web search settings updates", async () => {
|
|
await updateWebSearchSettings("tok", {
|
|
provider: "searxng",
|
|
baseUrl: "https://search.example.com",
|
|
maxResults: 8,
|
|
timeout: 45,
|
|
useJinaReader: false,
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com&max_results=8&timeout=45&use_jina_reader=false",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("serializes network safety settings updates", async () => {
|
|
await updateNetworkSafetySettings("tok", {
|
|
webuiAllowLocalServiceAccess: false,
|
|
webuiDefaultAccessMode: "full",
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("serializes image generation settings updates", async () => {
|
|
await updateImageGenerationSettings("tok", {
|
|
enabled: true,
|
|
provider: "openrouter",
|
|
model: "openai/gpt-5.4-image-2",
|
|
defaultAspectRatio: "16:9",
|
|
defaultImageSize: "2K",
|
|
maxImagesPerTurn: 3,
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/image-generation/update?enabled=true&provider=openrouter&model=openai%2Fgpt-5.4-image-2&default_aspect_ratio=16%3A9&default_image_size=2K&max_images_per_turn=3",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("reads CLI Apps catalog and serializes actions", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
apps: [],
|
|
installed_count: 0,
|
|
catalog_updated_at: "2026-04-18",
|
|
}),
|
|
} as Response);
|
|
|
|
await expect(fetchCliApps("tok")).resolves.toMatchObject({ apps: [] });
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/cli-apps",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
|
|
await runCliAppAction("tok", "install", "gimp");
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/cli-apps/install?name=gimp",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("reads MCP presets and serializes actions", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
presets: [],
|
|
installed_count: 0,
|
|
}),
|
|
} as Response);
|
|
|
|
await expect(fetchMcpPresets("tok")).resolves.toMatchObject({ presets: [] });
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/mcp-presets",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
|
|
await runMcpPresetAction("tok", "enable", "browserbase", {
|
|
browserbase_api_key: "bb_live_test",
|
|
});
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/mcp-presets/enable?name=browserbase",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer tok",
|
|
"X-Nanobot-MCP-Values": JSON.stringify({
|
|
browserbase_api_key: "bb_live_test",
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
|
|
await saveCustomMcpServer("tok", {
|
|
name: "docs",
|
|
transport: "stdio",
|
|
command: "npx",
|
|
args: '["-y","docs-mcp"]',
|
|
env: '{"API_KEY":"secret"}',
|
|
});
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/mcp-presets/custom",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer tok",
|
|
"X-Nanobot-MCP-Values": JSON.stringify({
|
|
name: "docs",
|
|
transport: "stdio",
|
|
command: "npx",
|
|
args: '["-y","docs-mcp"]',
|
|
env: '{"API_KEY":"secret"}',
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await importMcpConfig("tok", '{"mcpServers":{"docs":{"command":"npx"}}}');
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/mcp-presets/import",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer tok",
|
|
"X-Nanobot-MCP-Values": JSON.stringify({
|
|
config: '{"mcpServers":{"docs":{"command":"npx"}}}',
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await updateMcpServerTools("tok", "docs", ["search", "fetch"]);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings/mcp-presets/tools",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer tok",
|
|
"X-Nanobot-MCP-Values": JSON.stringify({
|
|
name: "docs",
|
|
enabled_tools: ["search", "fetch"],
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("reads and writes persisted sidebar state", async () => {
|
|
const state = {
|
|
schema_version: 1,
|
|
pinned_keys: ["websocket:chat-1"],
|
|
archived_keys: ["websocket:old"],
|
|
title_overrides: { "websocket:chat-1": "Release" },
|
|
project_name_overrides: { "/Users/me/nanobot": "Core" },
|
|
tags_by_key: {},
|
|
collapsed_groups: {},
|
|
view: {
|
|
density: "compact" as const,
|
|
show_previews: false,
|
|
show_timestamps: false,
|
|
show_archived: true,
|
|
sort: "updated_desc" as const,
|
|
},
|
|
updated_at: null,
|
|
};
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => state,
|
|
} as Response);
|
|
|
|
await expect(fetchSidebarState("tok")).resolves.toEqual(state);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/webui/sidebar-state",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
|
|
await updateSidebarState("tok", state);
|
|
const [url, init] = vi.mocked(fetch).mock.calls.at(-1)!;
|
|
expect(String(url).startsWith("/api/webui/sidebar-state/update?")).toBe(true);
|
|
expect(init).toEqual(expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}));
|
|
const encodedState = new URLSearchParams(String(url).split("?", 2)[1]).get("state");
|
|
expect(encodedState).toBeTruthy();
|
|
expect(JSON.parse(encodedState ?? "{}")).toMatchObject({
|
|
pinned_keys: ["websocket:chat-1"],
|
|
title_overrides: { "websocket:chat-1": "Release" },
|
|
project_name_overrides: { "/Users/me/nanobot": "Core" },
|
|
});
|
|
});
|
|
|
|
it("fetches workspace project state", async () => {
|
|
const payload = {
|
|
schema_version: 1,
|
|
default_access_mode: "default" as const,
|
|
default_scope: {
|
|
project_path: "/tmp/workspace",
|
|
project_name: "workspace",
|
|
access_mode: "restricted" as const,
|
|
restrict_to_workspace: true,
|
|
},
|
|
controls: {
|
|
can_change_project: true,
|
|
can_use_full_access: true,
|
|
},
|
|
};
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => payload,
|
|
} as Response);
|
|
|
|
await expect(fetchWorkspaces("tok")).resolves.toEqual(payload);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/workspaces",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("maps generated session titles from the sessions list", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
sessions: [
|
|
{
|
|
key: "websocket:chat-1",
|
|
created_at: "2026-05-01T10:00:00",
|
|
updated_at: "2026-05-01T10:01:00",
|
|
title: "优化 WebUI 标题",
|
|
run_started_at: 1_700_000_000,
|
|
},
|
|
],
|
|
}),
|
|
} as Response);
|
|
|
|
await expect(listSessions("tok")).resolves.toMatchObject([
|
|
{
|
|
key: "websocket:chat-1",
|
|
title: "优化 WebUI 标题",
|
|
preview: "",
|
|
runStartedAt: 1_700_000_000,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("maps slash command metadata from the commands endpoint", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
commands: [
|
|
{
|
|
command: "/stop",
|
|
title: "Stop current task",
|
|
description: "Cancel the active task.",
|
|
icon: "square",
|
|
},
|
|
{
|
|
command: "/restart",
|
|
title: "Restart nanobot",
|
|
description: "Restart the bot process.",
|
|
icon: "rotate-cw",
|
|
},
|
|
{
|
|
command: "/history",
|
|
title: "Show conversation history",
|
|
description: "Print the last N messages.",
|
|
icon: "history",
|
|
arg_hint: "[n]",
|
|
},
|
|
],
|
|
}),
|
|
} as Response);
|
|
|
|
await expect(listSlashCommands("tok")).resolves.toEqual([
|
|
{
|
|
command: "/history",
|
|
title: "Show conversation history",
|
|
description: "Print the last N messages.",
|
|
icon: "history",
|
|
argHint: "[n]",
|
|
},
|
|
]);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/commands",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
);
|
|
});
|
|
});
|