Files
nanobot/webui/src/tests/settings-view.test.tsx
T

318 lines
10 KiB
TypeScript
Raw Normal View History

2026-05-25 20:07:02 +08:00
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
import { ClientProvider } from "@/providers/ClientProvider";
import type { SettingsPayload } from "@/lib/types";
2026-05-25 20:07:02 +08:00
function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as Response;
}
function settingsPayload(): SettingsPayload {
2026-05-25 20:07:02 +08:00
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,
2026-05-25 20:07:02 +08:00
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_append_set: false,
},
requires_restart: false,
};
}
const installedAnyGen = {
name: "anygen",
display_name: "AnyGen",
category: "generation",
description: "Generate docs, slides, websites and more via AnyGen cloud API",
requires: "ANYGEN_API_KEY",
source: "harness",
entry_point: "cli-anything-anygen",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://www.google.com/s2/favicons?domain=anygen.io&sz=64",
brand_color: "#111827",
skill_installed: true,
};
function renderSettingsView(
options: {
2026-05-29 04:23:30 +08:00
initialSection?: "apps" | "advanced" | "models";
onSettingsChange?: (payload: SettingsPayload) => void;
} = {},
) {
2026-05-25 20:07:02 +08:00
render(
<ClientProvider client={{} as never} token="tok">
<SettingsView
theme="light"
initialSection={options.initialSection ?? "apps"}
2026-05-25 20:07:02 +08:00
onToggleTheme={() => {}}
onBackToChat={() => {}}
onModelNameChange={() => {}}
onSettingsChange={options.onSettingsChange}
2026-05-25 20:07:02 +08:00
/>
</ClientProvider>,
);
}
describe("SettingsView Apps catalog", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") {
return jsonResponse(settingsPayload());
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [installedAnyGen],
installed_count: 1,
catalog_updated_at: "2026-04-18",
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/cli-apps/uninstall?name=anygen") {
return jsonResponse({
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
installed_count: 0,
catalog_updated_at: "2026-04-18",
last_action: {
ok: true,
message: "Uninstalled CLI for AnyGen.",
still_available: false,
},
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView();
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
expect(await screen.findByText("AnyGen")).toBeInTheDocument();
const uninstall = screen.getByRole("button", { name: "Uninstall CLI" });
fireEvent.click(uninstall);
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/cli-apps/uninstall?name=anygen",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
expect(await screen.findByText("Uninstalled CLI for AnyGen.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
});
it("publishes the latest settings payload to the shell", async () => {
const payload = settingsPayload();
const onSettingsChange = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ onSettingsChange });
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
});
2026-05-29 04:23:30 +08:00
it("shows context window options in model settings", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
expect(await screen.findByText("Context window")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "64K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
});
it("saves network safety without exposing technical SSRF copy", async () => {
const payload = settingsPayload();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default") {
return jsonResponse({
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "advanced" });
expect(await screen.findByText("Web safety")).toBeInTheDocument();
expect(screen.queryByText(/SSRF/i)).not.toBeInTheDocument();
expect(screen.queryByText("Private Service Protection")).not.toBeInTheDocument();
expect(screen.getByText("Default access")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Restricted" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Default Permission" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Full Access" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
});
it("uses native host safety copy on the native surface", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "advanced" });
expect(await screen.findByText("App safety")).toBeInTheDocument();
expect(screen.queryByText("Web safety")).not.toBeInTheDocument();
expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument();
});
2026-05-25 20:07:02 +08:00
});