import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SettingsView } from "@/components/settings/SettingsView"; import { installedMcpPresetsFromPayload } from "@/lib/mcp-preset-events"; import { ClientProvider } from "@/providers/ClientProvider"; import type { ChannelSetupContract, ChannelSetupContractField, SettingsPayload, } from "@/lib/types"; const requestMutationMock = vi.fn(); function jsonResponse(body: unknown): Response { return { ok: true, status: 200, json: async () => body, } as Response; } function settingsPayload(): SettingsPayload { return { agent: { model: "openai/gpt-4o", provider: "auto", resolved_provider: "openai", has_api_key: true, model_preset: "primary", max_tokens: 8192, context_window_tokens: 200000, temperature: 0.1, reasoning_effort: null, timezone: "UTC", tool_hint_max_length: 40, }, model_presets: [{ name: "primary", label: "Primary", active: true, is_default: false, model: "openai/gpt-4o", provider: "auto", resolved_provider: "openai", max_tokens: 8192, context_window_tokens: 200000, temperature: 0.1, reasoning_effort: null, }], model_call_order: ["primary"], model_call_order_editable: true, 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 }, }, api: { host: "127.0.0.1", port: 8900, timeout: 120, api_key_hint: null, }, observability: { provider: "langfuse", configured: false, base_url: "https://cloud.langfuse.com", }, 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", }, 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_prepend_set: false, exec_path_append_set: false, }, requires_restart: false, version: { current: "0.2.2", }, docs: { version: "0.2.2", base_url: "https://nanobot.wiki/docs/0.2.2", chat_apps_url: "https://nanobot.wiki/docs/0.2.2/getting-started/chat-apps", latest_url: "https://nanobot.wiki/docs/latest", }, }; } function settingsPayloadWithBackup(): { payload: SettingsPayload; backupPreset: SettingsPayload["model_presets"][number]; } { const base = settingsPayload(); const backupPreset = { ...base.model_presets[0], name: "backup", label: "Backup", active: false, model: "anthropic/claude-sonnet-4", provider: "anthropic", resolved_provider: "anthropic", }; return { backupPreset, payload: { ...base, model_presets: [base.model_presets[0], backupPreset], model_call_order: ["primary", "backup"], providers: [ { name: "openai", label: "OpenAI", configured: true, }, { name: "anthropic", label: "Anthropic", configured: true, }, ], }, }; } function channelSetupField( channel: string, field: string, kind: ChannelSetupContractField["kind"] = "string", options: { required?: boolean; choices?: string[]; defaultValue?: string; } = {}, ): ChannelSetupContractField { return { key: `channels.${channel}.${field}`, field, kind, choices: options.choices ?? [], required: options.required ?? false, ...(options.defaultValue === undefined ? {} : { default_value: options.defaultValue }), }; } function channelSetupContract( channel: "discord" | "email" | "feishu" | "matrix" | "qq", ): ChannelSetupContract { const field = ( name: string, kind: ChannelSetupContractField["kind"] = "string", options: Parameters[3] = {}, ) => channelSetupField(channel, name, kind, options); switch (channel) { case "discord": return { official_url: "https://discord.com/developers/applications", fields: [ field("token", "secret", { required: true }), field("allowFrom", "list"), field("allowChannels", "list"), field("groupPolicy", "enum", { choices: ["mention", "open"], defaultValue: "mention", }), ], }; case "email": return { official_url: "https://support.google.com/accounts/answer/185833", fields: [ field("consentGranted", "bool", { required: true, defaultValue: "false" }), field("imapHost", "string", { required: true }), field("imapPort", "int"), field("imapUsername", "string", { required: true }), field("imapPassword", "secret", { required: true }), field("smtpHost", "string", { required: true }), field("smtpPort", "int"), field("smtpUsername", "string", { required: true }), field("smtpPassword", "secret", { required: true }), field("fromAddress"), field("pollIntervalSeconds", "int"), field("allowFrom", "list"), field("verifyDkim", "bool", { defaultValue: "true" }), field("verifySpf", "bool", { defaultValue: "true" }), ], }; case "feishu": return { official_url: "https://open.feishu.cn/app", fields: [ field("appId", "string", { required: true }), field("appSecret", "secret", { required: true }), field("domain", "enum", { choices: ["feishu", "lark"], defaultValue: "feishu", }), field("groupPolicy", "enum", { choices: ["mention", "open"], defaultValue: "mention", }), field("allowFrom", "list"), field("topicIsolation", "bool"), ], }; case "matrix": return { official_url: "https://matrix.org/ecosystem/clients/", fields: [ field("homeserver", "string", { required: true }), field("userId", "string", { required: true }), field("password", "secret"), field("accessToken", "secret"), field("deviceId"), field("groupPolicy", "enum", { choices: ["allowlist", "mention", "open"], defaultValue: "open", }), ], }; case "qq": return { official_url: "https://q.qq.com/", fields: [ field("appId", "string", { required: true }), field("secret", "secret", { required: true }), field("allowFrom", "list"), field("msgFormat", "enum", { choices: ["markdown", "plain"], defaultValue: "plain", }), ], }; } } function autoDynamicProviderPayload( options: { configured: boolean; hasApiKey: boolean; apiBase: string | null; apiKeyHint: string | null; }, ): SettingsPayload { const base = settingsPayload(); return { ...base, agent: { ...base.agent, model: "companyProxy/gpt-4o", provider: "companyProxy", resolved_provider: "companyProxy", has_api_key: options.hasApiKey, }, model_presets: [ { ...base.model_presets[0], model: "companyProxy/gpt-4o", provider: "auto", resolved_provider: "companyProxy", }, ], providers: [ { name: "companyProxy", label: "Company Proxy", configured: options.configured, auth_type: "api_key", api_key_required: false, api_key_hint: options.apiKeyHint, api_base: options.apiBase, default_api_base: null, }, ], }; } 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, }; const xmindMcpPreset = { name: "xmind", display_name: "Xmind", category: "productivity", description: "Create, read, and edit cloud mind maps through Xmind.", docs_url: "https://xmind.com/user-guide/xmind-mcp", transport: "streamableHttp", auth: "oauth" as const, requires: "Xmind account", note: "Connects securely in your browser with Xmind OAuth.", install_supported: true, installed: false, configured: false, available: false, status: "not_installed", logo_url: null, brand_color: "#F4B41A", required_fields: [], connection_summary: "", enabled_tools: ["*"], source: "preset", }; function renderSettingsView( options: { initialSection?: | "overview" | "appearance" | "apps" | "channels" | "automations" | "advanced" | "models" | "image" | "browser" | "runtime"; initialSettings?: SettingsPayload; showSidebar?: boolean; onBackToChat?: () => void; onSettingsChange?: (payload: SettingsPayload) => void; onNativeEngineRestart?: () => Promise; } = {}, ) { render( {}} onBackToChat={options.onBackToChat ?? (() => {})} onModelNameChange={() => {}} onSettingsChange={options.onSettingsChange} onNativeEngineRestart={options.onNativeEngineRestart} /> , ); } async function togglePresetEditor(name = "primary") { const row = await screen.findByTestId(`model-call-order-row-${name}`); fireEvent.click(within(row).getAllByRole("button")[0]); } async function openPopover(trigger: HTMLElement) { await userEvent.setup().click(trigger); } async function chooseProviderToConfigure(label: string) { fireEvent.pointerDown( await screen.findByRole("button", { name: "Add your own model provider" }), ); fireEvent.click(await screen.findByRole("menuitem", { name: label })); } describe("SettingsView Apps catalog", () => { const thirdPartyBrandNotice = "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."; beforeEach(() => { requestMutationMock.mockReset().mockResolvedValue(settingsPayload()); vi.stubGlobal( "matchMedia", vi.fn((query: string) => ({ matches: query === "(min-width: 1280px)", media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), })), ); vi.stubGlobal( "fetch", vi.fn(() => new Promise(() => {})), ); }); afterEach(() => { cleanup(); localStorage.removeItem("nanobot-webui.settings-preferences"); vi.useRealTimers(); vi.unstubAllGlobals(); }); it("persists the file edit display local preference", async () => { renderSettingsView({ initialSection: "appearance", initialSettings: settingsPayload(), showSidebar: true, }); expect(screen.getByText("File edit display")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Diff" })); await waitFor(() => { const saved = JSON.parse(localStorage.getItem("nanobot-webui.settings-preferences") || "{}"); expect(saved.fileEditDisplayMode).toBe("diff"); }); }); it("shows the third-party brand notice only with the brand logo preference", () => { renderSettingsView({ initialSection: "appearance", initialSettings: settingsPayload(), showSidebar: true, }); const brandLogosTitle = screen.getByText("Brand logos"); const brandLogosRow = brandLogosTitle.parentElement?.parentElement; expect(brandLogosRow).not.toBeNull(); expect( within(brandLogosRow as HTMLElement).getByText(thirdPartyBrandNotice), ).toBeInTheDocument(); expect(screen.getAllByText(thirdPartyBrandNotice)).toHaveLength(1); }); it.each(["apps", "channels"] as const)( "does not repeat the third-party brand notice in %s", (initialSection) => { renderSettingsView({ initialSection, initialSettings: settingsPayload() }); expect(screen.queryByText(thirdPartyBrandNotice)).not.toBeInTheDocument(); }, ); it("does not show the Settings kicker on the standalone Automations surface", async () => { const onBackToChat = vi.fn(); vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(settingsPayload()); if (url === "/api/webui/automations") return jsonResponse({ jobs: [] }); return jsonResponse({}); })); renderSettingsView({ initialSection: "automations", initialSettings: settingsPayload(), showSidebar: false, onBackToChat, }); expect(screen.getByRole("heading", { name: "Automations" })).toBeInTheDocument(); expect(await screen.findByText("No automations yet.")).toBeInTheDocument(); expect(screen.queryByText("Settings")).not.toBeInTheDocument(); expect( screen.queryByPlaceholderText("Search task, message, linked chat, or schedule"), ).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Open a chat" })); expect(onBackToChat).toHaveBeenCalledTimes(1); }); it("offers a way out of an empty automations filter", async () => { vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(settingsPayload()); if (url === "/api/webui/automations") { return jsonResponse({ jobs: [{ id: "job-1", name: "Daily summary", enabled: true, schedule: { kind: "cron", expr: "0 9 * * *" }, payload: { message: "Summarize the day" }, state: {}, }], }); } return jsonResponse({}); })); renderSettingsView({ initialSection: "automations", initialSettings: settingsPayload(), showSidebar: false, }); expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Paused 0" })); expect(await screen.findByText("No automations match this view.")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument(); }); it("coalesces focus refreshes while automations are already loading", async () => { let resolveAutomations!: (response: Response) => void; const pendingAutomations = new Promise((resolve) => { resolveAutomations = resolve; }); const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(settingsPayload()); if (url === "/api/webui/automations") return pendingAutomations; return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); renderSettingsView({ initialSection: "automations", initialSettings: settingsPayload(), showSidebar: false, }); await waitFor(() => { expect(fetchMock.mock.calls.filter(([input]) => ( String(input) === "/api/webui/automations" ))).toHaveLength(1); }); window.dispatchEvent(new Event("focus")); window.dispatchEvent(new Event("focus")); expect(fetchMock.mock.calls.filter(([input]) => ( String(input) === "/api/webui/automations" ))).toHaveLength(1); await act(async () => { resolveAutomations(jsonResponse({ jobs: [] })); await pendingAutomations; }); }); it("starts the managed API server from System", async () => { const base = settingsPayload(); const stopped = { installed: false, running: false, managed: false, host: "127.0.0.1", port: 8900, timeout: 120, api_key_hint: null, endpoint: "http://127.0.0.1:8900/v1", command: "nanobot serve", }; const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(base); if (url === "/api/settings/api-service") return jsonResponse(stopped); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [], enabled_count: 0 }); } return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce({ ...stopped, installed: true, running: true, managed: true, }); renderSettingsView({ initialSection: "runtime", initialSettings: base, showSidebar: true }); const startButton = await screen.findByRole("button", { name: "Start API server" }); await waitFor(() => expect(startButton).toBeEnabled()); fireEvent.click(startButton); await waitFor(() => { expect(requestMutationMock).toHaveBeenCalledWith( "settings.api_service.start", { host: "127.0.0.1", port: 8900, timeout: 120 }, 150_000, ); }); }); 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 }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce({ 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, }, }); renderSettingsView(); expect(screen.queryByRole("heading", { name: "Apps" })).not.toBeInTheDocument(); expect(await screen.findByText("AnyGen")).toBeInTheDocument(); const uninstall = screen.getByRole("button", { name: "Uninstall app" }); fireEvent.click(uninstall); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.cli_app.uninstall", { name: "anygen" }, 20_000, ), ); 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("enables and disables an installed Agent Plugin explicitly", async () => { const plugin = { name: "plugin-computer-use", display_name: "Computer Use", description: "Control the desktop with a live preview.", category: "Productivity", docs_url: "https://github.com/nanobot-dev/computer-use", transport: "stdio", requires: "screen-recording, accessibility", note: "", install_supported: false, installed: true, configured: true, enabled: false, available: false, status: "disabled", logo_url: null, brand_color: "#ff7a1a", required_fields: [], connection_summary: "computer-use", source: "agent-plugin", }; expect(installedMcpPresetsFromPayload({ presets: [plugin], installed_count: 0 })).toEqual([]); 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: [], installed_count: 0 }); } if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [plugin], installed_count: 0 }); } return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); requestMutationMock .mockResolvedValueOnce({ presets: [{ ...plugin, enabled: true, available: true, status: "enabled" }], installed_count: 1, last_action: { ok: true, message: "Computer Use enabled." }, }) .mockResolvedValueOnce({ presets: [{ ...plugin, enabled: false, available: false, status: "disabled" }], installed_count: 0, last_action: { ok: true, message: "Computer Use disabled." }, }); renderSettingsView(); expect(await screen.findByText("Computer Use")).toBeInTheDocument(); expect(screen.getByText("Plugins")).toBeInTheDocument(); expect( screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Enable" })); await waitFor(() => { expect(requestMutationMock).toHaveBeenCalledWith( "settings.mcp.enable", { name: "plugin-computer-use" }, 20_000, ); }); const enabledButton = await screen.findByRole("button", { name: "Computer Use: Enabled" }); await waitFor(() => expect(enabledButton).toBeEnabled()); fireEvent.pointerDown(enabledButton, { button: 0, ctrlKey: false }); fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" })); await waitFor(() => { expect(requestMutationMock).toHaveBeenCalledWith( "settings.mcp.disable", { name: "plugin-computer-use" }, 20_000, ); }); expect(await screen.findByRole("button", { name: "Enable" })).toBeInTheDocument(); }); it("connects an OAuth MCP from the Apps catalog without manual callback input", async () => { let connected = false; 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: [], installed_count: 0 }); } if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [connected ? { ...xmindMcpPreset, installed: true, configured: true, available: true, status: "configured", connection_summary: "https://app.xmind.com/api/mcp", } : xmindMcpPreset], installed_count: connected ? 1 : 0, }); } if (url === "/api/settings/mcp-oauth/status?flow_id=flow-123") { connected = true; return jsonResponse({ flow_id: "flow-123", name: "xmind", status: "connected", expires_in: 295, hot_reload: { ok: false, requires_restart: false, connected: ["xmind"], failed: ["notion"], message: "MCP config reloaded, but some servers did not connect: notion", }, }); } return { ok: false, status: 404, text: async () => "Not found" } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockImplementation(async (action: string) => { if (action === "settings.mcp.oauth_start") { return { flow_id: "flow-123", name: "xmind", status: "authorization_required", expires_in: 300, authorization_url: "https://accounts.xmind.test/authorize?state=state-123", }; } return settingsPayload(); }); const replace = vi.fn(); const popup = { opener: window, closed: false, location: { replace }, document: { title: "", body: { textContent: "" } }, focus: vi.fn(), close: vi.fn(), }; const open = vi.fn(() => popup); vi.stubGlobal("open", open); renderSettingsView({ initialSection: "apps" }); fireEvent.click(await screen.findByRole("button", { name: "MCP" })); expect(screen.getByText("MCP tools")).toBeInTheDocument(); const connectButton = await screen.findByRole("button", { name: "Connect Xmind" }); expect(connectButton).toHaveTextContent("Connect"); fireEvent.click(connectButton); expect(open).toHaveBeenCalledWith( "about:blank", "nanobot-mcp-oauth", "popup,width=560,height=720,resizable=yes,scrollbars=yes", ); await waitFor(() => expect(replace).toHaveBeenCalledWith( "https://accounts.xmind.test/authorize?state=state-123", )); expect(popup.opener).toBeNull(); expect(screen.queryByRole("textbox", { name: /authorization/i })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.getByRole("status")).toHaveTextContent( "Finish signing in in the browser window.", ); expect(screen.getByRole("button", { name: "Connecting Xmind" })).toHaveTextContent( "Connecting…", ); expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 })) .toHaveTextContent("Configured"); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(screen.queryByText("Xmind connected.")).not.toBeInTheDocument(); expect(screen.queryByText(/some servers did not connect: notion/i)).not.toBeInTheDocument(); expect(popup.close).toHaveBeenCalledTimes(1); expect(replace).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( "/api/settings/mcp-oauth/status?flow_id=flow-123", expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), ); }); it("configures OAuth for a custom remote MCP without importing JSON", async () => { const customPreset = { ...xmindMcpPreset, name: "team-mcp", display_name: "team-mcp", source: "custom", installed: true, status: "authorization_required", connection_summary: "https://mcp.example.com/mcp", }; 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: [], installed_count: 0 }); } if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } return { ok: false, status: 404, text: async () => "Not found" } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockImplementation(async (action: string) => { if (action === "settings.mcp.custom") { return { presets: [customPreset], installed_count: 1, hot_reload: { ok: false, message: "MCP config reloaded, but some servers did not connect: team-mcp", failed: ["team-mcp"], }, last_action: { ok: true, message: "Saved custom MCP server team-mcp." }, }; } return settingsPayload(); }); renderSettingsView({ initialSection: "apps" }); fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "Custom" })); expect(screen.queryByText("Authentication")).not.toBeInTheDocument(); fireEvent.change(screen.getByLabelText("Server name"), { target: { value: "team-mcp" }, }); fireEvent.click(screen.getByRole("button", { name: "HTTP" })); fireEvent.change(screen.getByLabelText("URL"), { target: { value: "https://mcp.example.com/mcp" }, }); const authentication = screen.getByRole("group", { name: "Authentication" }); const oauth = within(authentication).getByRole("button", { name: "OAuth" }); expect(oauth).toHaveAttribute("aria-pressed", "false"); fireEvent.click(within(authentication).getByRole("button", { name: "Headers" })); fireEvent.change(screen.getByLabelText("Headers JSON"), { target: { value: '{"Authorization":"Bearer stale"}' }, }); expect(screen.getByText("Add the request headers used by this server.")).toBeInTheDocument(); fireEvent.click(oauth); expect(oauth).toHaveAttribute("aria-pressed", "true"); expect(screen.queryByLabelText("Headers JSON")).not.toBeInTheDocument(); expect( screen.getByText("Save the server, then select Connect to sign in."), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Save MCP" })); await waitFor(() => { const saveCall = requestMutationMock.mock.calls.find( ([action]) => action === "settings.mcp.custom", ); expect(saveCall).toBeDefined(); const values = saveCall?.[1] as Record; expect(values).toMatchObject({ name: "team-mcp", transport: "streamableHttp", url: "https://mcp.example.com/mcp", auth: "oauth", }); expect(values).not.toHaveProperty("headers"); expect(saveCall?.[2]).toBe(20_000); }); expect(await screen.findByRole("button", { name: "Connect team-mcp" })) .toBeInTheDocument(); expect( screen.queryByText("MCP config reloaded, but some servers did not connect: team-mcp"), ).not.toBeInTheDocument(); }); it("offers a pasted callback flow when the remote WebUI uses HTTP", async () => { let completed = false; const callbackUrl = "http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=manual-state"; 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: [], installed_count: 0 }); } if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [completed ? { ...xmindMcpPreset, installed: true, configured: true, available: true, status: "configured", connection_summary: "https://app.xmind.com/api/mcp", } : xmindMcpPreset], installed_count: completed ? 1 : 0, }); } if (url === "/api/settings/mcp-oauth/status?flow_id=flow-manual") { return jsonResponse({ flow_id: "flow-manual", name: "xmind", status: completed ? "connected" : "authorization_required", expires_in: 298, completion_input: "callback_url", authorization_url: completed ? undefined : "https://accounts.xmind.test/authorize?state=manual-state", hot_reload: completed ? { ok: true, requires_restart: false } : undefined, }); } return { ok: false, status: 404, text: async () => "Not found" } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockImplementation(async (action: string) => { if (action === "settings.mcp.oauth_start") { return { flow_id: "flow-manual", name: "xmind", status: "authorization_required", expires_in: 300, completion_input: "callback_url", authorization_url: "https://accounts.xmind.test/authorize?state=manual-state", }; } if (action === "settings.mcp.oauth_complete") { completed = true; return { flow_id: "flow-manual", name: "xmind", status: "connecting", expires_in: 299, completion_input: "callback_url", }; } return settingsPayload(); }); const popup = { opener: window, closed: false, location: { replace: vi.fn() }, document: { title: "", body: { textContent: "" } }, focus: vi.fn(), close: vi.fn(), }; vi.stubGlobal("open", vi.fn(() => popup)); renderSettingsView({ initialSection: "apps" }); fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); const callbackInput = await screen.findByRole("textbox", { name: "Full callback URL" }); expect(screen.getByText(/localhost page will not load/i)).toBeInTheDocument(); expect(screen.getByRole("status")).toHaveTextContent( "Finish signing in, then paste the callback URL into nanobot.", ); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); fireEvent.change(callbackInput, { target: { value: callbackUrl } }); fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.mcp.oauth_complete", { flow_id: "flow-manual", callback_url: callbackUrl }, 20_000, )); expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 })) .toHaveTextContent("Configured"); expect(screen.queryByRole("textbox", { name: "Full callback URL" })).not.toBeInTheDocument(); expect(popup.close).toHaveBeenCalledTimes(1); }); it("lets the user cancel an active OAuth connection after closing the popup", 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 }); } if (url === "/api/settings/mcp-oauth/status?flow_id=flow-cancel") { return new Promise(() => {}); } return { ok: false, status: 404, text: async () => "Not found" } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockImplementation(async (action: string) => { if (action === "settings.mcp.oauth_start") { return { flow_id: "flow-cancel", name: "xmind", status: "authorization_required", expires_in: 300, authorization_url: "https://accounts.xmind.test/authorize?state=cancel", }; } if (action === "settings.mcp.oauth_cancel") { return { flow_id: "flow-cancel", name: "xmind", status: "cancelled", expires_in: 299, }; } return settingsPayload(); }); const popup = { opener: window, closed: false, location: { replace: vi.fn() }, document: { title: "", body: { textContent: "" } }, focus: vi.fn(), close: vi.fn(), }; vi.stubGlobal("open", vi.fn(() => popup)); renderSettingsView({ initialSection: "apps" }); fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); const cancelButton = await screen.findByRole("button", { name: "Cancel" }); expect(screen.getByRole("button", { name: "Connecting Xmind" })).toBeInTheDocument(); popup.closed = true; fireEvent.click(cancelButton); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.mcp.oauth_cancel", { flow_id: "flow-cancel" }, 20_000, )); expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Connecting Xmind" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(popup.close).not.toHaveBeenCalled(); }); it("silently removes an MCP when the card already shows the result", 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: [], installed_count: 0 }); } if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [{ ...xmindMcpPreset, installed: true, configured: true, available: true, status: "configured", connection_summary: "https://app.xmind.com/api/mcp", }], installed_count: 1, }); } return { ok: false, status: 404, text: async () => "Not found" } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce({ presets: [xmindMcpPreset], installed_count: 0, requires_restart: false, hot_reload: { ok: true, message: "MCP config reloaded without restarting nanobot.", }, last_action: { ok: true, message: "Removed MCP preset for Xmind. MCP config reloaded without restarting nanobot.", removed: true, }, }); renderSettingsView({ initialSection: "apps" }); fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "Remove" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.mcp.remove", { name: "xmind" }, 20_000, )); expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument(); expect(screen.queryByText(/Removed MCP preset|reloaded without restarting/)).not.toBeInTheDocument(); }); it("offers a one-click recovery when the OAuth popup is blocked", 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 }); } return new Promise(() => {}); }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockImplementation(async (action: string) => { if (action === "settings.mcp.oauth_start") { return { flow_id: "flow-blocked", name: "xmind", status: "authorization_required", expires_in: 300, authorization_url: "https://accounts.xmind.test/authorize?state=blocked", }; } return settingsPayload(); }); const popup = { opener: window, closed: false, location: { replace: vi.fn() }, focus: vi.fn(), close: vi.fn(), }; const open = vi.fn() .mockReturnValueOnce(null) .mockReturnValueOnce(popup); vi.stubGlobal("open", open); renderSettingsView({ initialSection: "apps" }); fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); const continueButton = await screen.findByRole("button", { name: "Continue sign-in" }); expect(screen.getByRole("status")).toHaveTextContent("Open the sign-in page to continue."); expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); fireEvent.click(continueButton); expect(open).toHaveBeenLastCalledWith( "https://accounts.xmind.test/authorize?state=blocked", "nanobot-mcp-oauth", "popup,width=560,height=720,resizable=yes,scrollbars=yes", ); expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); }); it("does not mistake a COOP-isolated OAuth tab for a blocked popup", async () => { let popupIsolated = false; let statusCalls = 0; 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 }); } if (url === "/api/settings/mcp-oauth/status?flow_id=flow-coop") { statusCalls += 1; return jsonResponse({ flow_id: "flow-coop", name: "xmind", status: statusCalls === 1 ? "authorization_required" : "failed", expires_in: 299, error: statusCalls === 1 ? undefined : "Cancelled for test cleanup.", authorization_url: statusCalls === 1 ? "https://accounts.xmind.test/authorize?state=coop" : undefined, }); } return { ok: false, status: 404, text: async () => "Not found" } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockImplementation(async (action: string) => { if (action === "settings.mcp.oauth_start") { return { flow_id: "flow-coop", name: "xmind", status: "authorization_required", expires_in: 300, authorization_url: "https://accounts.xmind.test/authorize?state=coop", }; } return settingsPayload(); }); const popup = { opener: window, get closed() { return popupIsolated; }, location: { replace: vi.fn(() => { popupIsolated = true; }), }, document: { title: "", body: { textContent: "" } }, focus: vi.fn(), close: vi.fn(), }; vi.stubGlobal("open", vi.fn(() => popup)); renderSettingsView({ initialSection: "apps" }); fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); await waitFor(() => expect(statusCalls).toBe(1), { timeout: 2000 }); expect(screen.getByRole("status")).toHaveTextContent( "Finish signing in in the browser window.", ); expect(screen.queryByRole("button", { name: "Continue sign-in" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); }); it("keeps runtime dependencies out of Apps and explains chat mentions", 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: [{ ...installedAnyGen, installed: false, status: "available" }], installed_count: 0, }); } if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [ { name: "api", display_name: "Api", type: "feature", enabled: true, installed: true, ready: true, status: "enabled", install_supported: true, requires_restart: true, }, ], enabled_count: 1, }); } return jsonResponse({}); })); renderSettingsView({ initialSection: "apps" }); expect(await screen.findByText("AnyGen")).toBeInTheDocument(); expect( screen.queryByText("Add tools to nanobot, then @ them in chat."), ).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false"); expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: "MCP" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument(); expect(screen.queryByText("Api")).not.toBeInTheDocument(); expect(screen.queryByText("0 ready")).not.toBeInTheDocument(); }); it("shows nanobot optional features and enables one", 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "matrix", display_name: "Matrix", webui: "webui/index.ts", type: "channel", enabled: false, installed: false, ready: false, status: "missing_dependency", install_supported: true, requires_restart: true, }], enabled_count: 0, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockImplementation(async (action: string) => { if (action === "settings.feature.enable") { return { features: [{ name: "matrix", display_name: "Matrix", webui: "webui/index.ts", type: "channel", enabled: true, running: true, runtime_status: "running", installed: true, ready: true, status: "enabled", install_supported: true, requires_restart: true, }], enabled_count: 1, last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true }, }; } if (action === "settings.feature.disable") { return { features: [{ name: "matrix", display_name: "Matrix", webui: "webui/index.ts", type: "channel", enabled: false, installed: true, ready: false, status: "not_enabled", install_supported: true, requires_restart: true, }], enabled_count: 0, requires_restart: true, last_action: { ok: true, message: "Disabled channel 'matrix'", enabled: false }, }; } return settingsPayload(); }); renderSettingsView({ initialSection: "channels" }); const matrixRow = await screen.findByRole("button", { name: "View Matrix settings" }); expect(matrixRow).toHaveAttribute("aria-pressed", "true"); expect(screen.getAllByText("Matrix")).toHaveLength(2); expect(screen.getAllByText("Use nanobot from Matrix rooms.")).toHaveLength(2); expect(screen.queryByText(/Enabling Nanobot features may install Python packages/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("switch", { name: "Matrix channel" })); expect(screen.getByRole("dialog", { name: "Install support for Matrix?" })).toBeInTheDocument(); expect(screen.getByText("nanobot will add what Matrix needs, then turn it on. Continue?")).toBeInTheDocument(); expect(requestMutationMock).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Install and enable" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.feature.enable", { name: "matrix" }, 150_000, ), ); await waitFor(() => expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "true"), ); expect(screen.queryByText("Enabled channel 'matrix'")).not.toBeInTheDocument(); expect(screen.queryByText("Restart nanobot to apply updated channel support.")).not.toBeInTheDocument(); expect(screen.getAllByText("On").length).toBeGreaterThan(0); expect(screen.getByLabelText("Homeserver")).toBeInTheDocument(); expect(screen.getByLabelText("User ID")).toBeInTheDocument(); expect(screen.getByLabelText("Device ID")).toBeInTheDocument(); expect(screen.queryByText("channels.matrix.homeserver")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("switch", { name: "Matrix channel" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.feature.disable", { name: "matrix" }, 20_000, ), ); await waitFor(() => expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "false"), ); expect(screen.queryByText("Disabled channel 'matrix'")).not.toBeInTheDocument(); }); it("shows an enabled channel with missing support as failed", 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "matrix", display_name: "Matrix", type: "channel", enabled: true, running: false, runtime_status: "failed", runtime_error: "Channel dependencies could not be installed. Check gateway logs.", installed: false, ready: false, status: "missing_dependency", install_supported: true, requires_restart: true, }], enabled_count: 1, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce({ features: [{ name: "matrix", display_name: "Matrix", type: "channel", enabled: true, installed: true, ready: true, status: "enabled", install_supported: true, requires_restart: true, }], enabled_count: 1, last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true }, }); renderSettingsView({ initialSection: "channels" }); expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument(); expect(screen.queryByText("0 running · 1 channels")).not.toBeInTheDocument(); expect(screen.getAllByText("Failed").length).toBeGreaterThan(0); expect(screen.queryByText("Enabled, support needs install")).not.toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "false"); fireEvent.click(screen.getByRole("button", { name: "Install support" })); fireEvent.click(screen.getByRole("button", { name: "Install and enable" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.feature.enable", { name: "matrix" }, 150_000, ), ); }); it("shows a configured channel as failed when its runtime did not start", async () => { const runtimeError = "Channel failed to start. Check gateway logs."; 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 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "matrix", display_name: "Matrix", type: "channel", enabled: true, configured: true, installed: true, ready: false, running: false, runtime_status: "failed", runtime_error: runtimeError, status: "failed", install_supported: true, requires_restart: false, }], enabled_count: 0, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }), ); renderSettingsView({ initialSection: "channels" }); expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument(); expect(screen.queryByText("0 running · 1 channels")).not.toBeInTheDocument(); expect(screen.getAllByText("Failed").length).toBeGreaterThan(0); expect(screen.getByText(runtimeError)).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute( "aria-checked", "false", ); }); it("starts Feishu connect in WebUI instead of showing a CLI command", 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "feishu", display_name: "Feishu", webui: "webui/index.tsx", type: "channel", enabled: false, configured: false, installed: true, ready: false, status: "not_enabled", install_supported: true, requires_restart: true, }], enabled_count: 0, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce({ session_id: "feishu-session", status: "pending", qr_url: "https://accounts.feishu.cn/login?device_code=device", domain: "feishu", interval_ms: 5000, expires_at_ms: Date.now() + 600_000, message: "Scan with Feishu or Lark to connect.", }); renderSettingsView({ initialSection: "channels" }); expect(await screen.findByRole("button", { name: "View Feishu settings" })).toBeInTheDocument(); expect(screen.queryByText("nanobot channels login feishu")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "nanobot" })); fireEvent.click(screen.getByRole("button", { name: "Connect" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.channel.connect.start", { channel: "feishu", domain: "feishu", instance_id: "default", mode: "replace" }, 150_000, ), ); expect(await screen.findByText("Scan with Feishu")).toBeInTheDocument(); expect(screen.getByText("Waiting for authorization...")).toBeInTheDocument(); }); it("starts Feishu connect from the default assistant action", 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "feishu", display_name: "Feishu", webui: "webui/index.tsx", type: "channel", enabled: false, configured: false, installed: true, ready: false, status: "not_enabled", install_supported: true, requires_restart: true, }], enabled_count: 0, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce({ session_id: "feishu-switch-session", status: "pending", qr_url: "https://accounts.feishu.cn/login?device_code=switch-device", domain: "feishu", interval_ms: 5000, expires_at_ms: Date.now() + 600_000, message: "Scan with Feishu or Lark to connect.", }); renderSettingsView({ initialSection: "channels" }); expect(await screen.findByRole("button", { name: "View Feishu settings" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "nanobot" })); fireEvent.click(screen.getByRole("button", { name: "Connect" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.channel.connect.start", { channel: "feishu", domain: "feishu", instance_id: "default", mode: "replace" }, 150_000, ), ); expect(await screen.findByText("Scan with Feishu")).toBeInTheDocument(); }); it("enables configured Feishu assistant without starting a new connect flow", 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "feishu", display_name: "Feishu", webui: "webui/index.tsx", type: "channel", enabled: false, configured: true, installed: true, ready: false, status: "not_enabled", install_supported: true, requires_restart: true, }], enabled_count: 0, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockImplementation(async (action: string) => { if (action === "settings.feature.enable") { return { features: [{ name: "feishu", display_name: "Feishu", webui: "webui/index.tsx", type: "channel", enabled: true, running: true, runtime_status: "running", configured: true, instances: [{ id: "default", name: "nanobot", enabled: true, running: true, runtime_status: "running", configured: true, config_values: { "channels.feishu.appId": "cli_test" }, configured_fields: [ "channels.feishu.appId", "channels.feishu.appSecret", ], }], installed: true, ready: true, status: "enabled", install_supported: true, requires_restart: true, }], enabled_count: 1, requires_restart: false, last_action: { ok: true, message: "Enabled channel 'feishu'", enabled: true }, }; } if (action === "settings.channel.connect.start") { throw new Error("Feishu connect should not start when credentials are already configured"); } return settingsPayload(); }); renderSettingsView({ initialSection: "channels" }); fireEvent.click(await screen.findByRole("button", { name: "nanobot" })); fireEvent.click(await screen.findByRole("switch", { name: "nanobot assistant" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.feature.enable", { name: "feishu", instance_id: "default" }, 150_000, ), ); expect(requestMutationMock.mock.calls.some(([action]) => ( action === "settings.channel.connect.start" ))).toBe(false); expect(screen.getByRole("switch", { name: "nanobot assistant" })).toHaveAttribute( "aria-checked", "true", ); }); it("shows Feishu assistant instances in the channel details", 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 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "feishu", display_name: "Feishu", webui: "webui/index.tsx", type: "channel", enabled: true, configured: true, installed: true, ready: true, status: "enabled", install_supported: true, requires_restart: true, setup: channelSetupContract("feishu"), instances: [ { id: "default", name: "nanobot", display_name: "Support Bot", avatar_url: "https://example.com/support.png", enabled: true, configured: true, config_values: { "channels.feishu.appId": "cli_default", "channels.feishu.domain": "feishu", "channels.feishu.groupPolicy": "mention", "channels.feishu.allowFrom": "", "channels.feishu.topicIsolation": "true", }, configured_fields: [ "channels.feishu.appId", "channels.feishu.appSecret", "channels.feishu.domain", "channels.feishu.groupPolicy", "channels.feishu.topicIsolation", ], }, { id: "product", name: "Product bot", display_name: "Product Helper", avatar_url: "https://example.com/product.png", enabled: false, configured: true, config_values: { "channels.feishu.appId": "cli_product" }, configured_fields: [ "channels.feishu.appId", "channels.feishu.appSecret", ], }, ], }], enabled_count: 1, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }), ); renderSettingsView({ initialSection: "channels" }); expect(await screen.findByText("Product Helper")).toBeInTheDocument(); expect(screen.getAllByText("Support Bot")).toHaveLength(1); expect(document.querySelector('img[src="https://example.com/support.png"]')).toBeTruthy(); expect(screen.getByRole("button", { name: /Support Bot/ })).toHaveAttribute( "aria-expanded", "false", ); expect(screen.getByRole("button", { name: /Product Helper/ })).toHaveAttribute( "aria-expanded", "false", ); expect(screen.queryByText("cli_def...ault")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /Support Bot/ })); expect(screen.getByRole("button", { name: /Support Bot/ })).toHaveAttribute( "aria-expanded", "true", ); expect(screen.getAllByText("cli_def...ault").length).toBeGreaterThan(0); expect(screen.getByText("Advanced")).toBeInTheDocument(); expect(screen.getByText("Topic isolation")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /Product Helper/ })); expect(screen.getByRole("button", { name: /Support Bot/ })).toHaveAttribute( "aria-expanded", "false", ); expect(screen.getByRole("button", { name: /Product Helper/ })).toHaveAttribute( "aria-expanded", "true", ); }); it("renders external multi-instance channels from the shared contract", 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "multiplugin", display_name: "Multi Plugin", type: "channel", enabled: true, running: true, runtime_status: "running", configured: true, installed: true, ready: true, status: "enabled", install_supported: true, requires_restart: false, setup: { fields: [ channelSetupField("multiplugin", "token", "secret", { required: true }), channelSetupField("multiplugin", "region", "enum", { choices: ["eu", "us"], defaultValue: "us", }), ], }, instances: [ { id: "default", name: "Default worker", enabled: true, running: true, runtime_status: "running", configured: true, config_values: { "channels.multiplugin.region": "us" }, configured_fields: ["channels.multiplugin.token"], }, { id: "product", name: "Product worker", enabled: true, running: true, runtime_status: "running", configured: true, config_values: { "channels.multiplugin.region": "eu" }, configured_fields: ["channels.multiplugin.token"], }, ], }], enabled_count: 1, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); renderSettingsView({ initialSection: "channels" }); expect(await screen.findByText("Default worker")).toBeInTheDocument(); expect(screen.queryByRole("switch", { name: "Multi Plugin channel" })).not.toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Default worker instance" })).toHaveAttribute( "aria-checked", "true", ); expect(screen.getByRole("switch", { name: "Product worker instance" })).toHaveAttribute( "aria-checked", "true", ); fireEvent.click(screen.getByRole("button", { name: "Product worker" })); expect(screen.getByRole("radio", { name: "Eu" })).toHaveAttribute("aria-checked", "true"); expect(screen.getByText("Saved")).toBeInTheDocument(); }); it("shows a single Feishu assistant without a duplicate assistant list", async () => { const feishuPayload = { features: [{ name: "feishu", display_name: "Feishu", webui: "webui/index.tsx", type: "channel", enabled: true, configured: true, installed: true, ready: true, status: "enabled", running: true, runtime_status: "running", install_supported: true, requires_restart: true, instances: [{ id: "default", name: "nanobot", display_name: "Support Bot", avatar_url: "https://example.com/support.png", enabled: true, running: true, runtime_status: "running", configured: true, config_values: { "channels.feishu.appId": "cli_support" }, configured_fields: [ "channels.feishu.appId", "channels.feishu.appSecret", ], }], }], enabled_count: 1, }; 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 }); if (url === "/api/settings/nanobot-features") return jsonResponse(feishuPayload); return { ok: false, status: 404, json: async () => ({}) } as Response; }), ); requestMutationMock.mockResolvedValueOnce(feishuPayload); renderSettingsView({ initialSection: "channels" }); await screen.findByText("Support Bot"); expect(screen.getAllByText("Support Bot")).toHaveLength(1); expect(screen.getByText("1 assistant connected")).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Support Bot assistant" })).toHaveAttribute( "aria-checked", "true", ); expect(screen.queryByText("cli_sup...port")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Support Bot" })); expect(screen.getByText("cli_sup...port")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Replace assistant" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Reconnect" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.feature.enable", { name: "feishu", instance_id: "default" }, 150_000, )); expect(document.querySelector('img[src="https://example.com/support.png"]')).toBeTruthy(); }); it("does not call a configured Feishu assistant connected after runtime failure", async () => { const runtimeError = "Channel failed to start. Check gateway logs."; 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 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "feishu", display_name: "Feishu", webui: "webui/index.tsx", type: "channel", enabled: true, configured: true, installed: true, ready: false, running: false, runtime_status: "failed", runtime_error: runtimeError, status: "failed", install_supported: true, requires_restart: false, instances: [{ id: "default", name: "test", enabled: true, configured: true, running: false, runtime_status: "failed", runtime_error: runtimeError, config_values: { "channels.feishu.appId": "cli_test" }, configured_fields: [ "channels.feishu.appId", "channels.feishu.appSecret", ], }], }], enabled_count: 0, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }), ); renderSettingsView({ initialSection: "channels" }); await screen.findByText("No assistant connected"); expect(screen.queryByText("0 running · 1 channels")).not.toBeInTheDocument(); expect(screen.getAllByText("Failed").length).toBeGreaterThan(0); expect(screen.getByText(runtimeError)).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "test assistant" })).toHaveAttribute( "aria-checked", "false", ); expect(screen.queryByText("Connected")).not.toBeInTheDocument(); }); it("shows group behavior fields as options", 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 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "discord", display_name: "Discord", webui: "webui/index.ts", type: "channel", enabled: true, installed: true, ready: true, status: "enabled", install_supported: true, requires_restart: true, setup: channelSetupContract("discord"), }], enabled_count: 1, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }), ); renderSettingsView({ initialSection: "channels" }); expect(await screen.findByRole("button", { name: "View Discord settings" })).toBeInTheDocument(); fireEvent.click(screen.getByText("Advanced")); const behavior = screen.getByRole("radiogroup", { name: "Group behavior" }); expect(within(behavior).getByRole("radio", { name: "Mention only" })).toHaveAttribute( "aria-checked", "true", ); expect(within(behavior).getByRole("radio", { name: "All messages" })).toBeInTheDocument(); expect(screen.queryByPlaceholderText("mention")).not.toBeInTheDocument(); fireEvent.click(within(behavior).getByRole("radio", { name: "All messages" })); expect(within(behavior).getByRole("radio", { name: "All messages" })).toHaveAttribute( "aria-checked", "true", ); }); it("uses a list-to-detail navigation stack on compact screens", async () => { vi.stubGlobal( "matchMedia", vi.fn((query: string) => ({ matches: false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), })), ); 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 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "email", display_name: "Email", type: "channel", enabled: false, installed: true, ready: false, status: "not_enabled", install_supported: true, requires_restart: true, }], enabled_count: 0, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }), ); renderSettingsView({ initialSection: "channels" }); const emailRow = await screen.findByRole("button", { name: "View Email settings" }); expect(screen.getByPlaceholderText("Search channels")).toHaveClass( "focus-visible:ring-0", "focus-visible:ring-offset-0", ); expect(screen.queryByRole("switch", { name: "Email channel" })).not.toBeInTheDocument(); fireEvent.click(emailRow); expect(screen.getByRole("button", { name: "All channels" })).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Email channel" })).toBeInTheDocument(); expect(screen.queryByPlaceholderText("Search channels")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "All channels" })); expect(screen.getByPlaceholderText("Search channels")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "View Email settings" })).toBeInTheDocument(); }); it("saves Discord credentials from the channel setup panel", 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "discord", display_name: "Discord", webui: "webui/index.ts", type: "channel", enabled: false, configured: false, installed: true, ready: false, status: "not_enabled", install_supported: true, requires_restart: true, setup: channelSetupContract("discord"), }], enabled_count: 0, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock .mockResolvedValueOnce({ name: "discord", status: "ready", checks: [], missing_fields: [], can_enable: true, requires_restart: false, }) .mockResolvedValueOnce({ name: "discord", saved: true, saved_keys: [ "channels.discord.token", "channels.discord.allowChannels", "channels.discord.groupPolicy", ], nanobot_features: { features: [{ name: "discord", display_name: "Discord", webui: "webui/index.ts", type: "channel", enabled: true, running: true, runtime_status: "running", configured: true, installed: true, ready: true, status: "enabled", install_supported: true, requires_restart: true, setup: channelSetupContract("discord"), }], enabled_count: 1, requires_restart: false, }, }); renderSettingsView({ initialSection: "channels" }); expect(await screen.findByRole("button", { name: "View Discord settings" })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Open Discord setup" })).toHaveAttribute( "href", "https://nanobot.wiki/docs/0.2.2/getting-started/chat-apps#discord", ); expect(screen.getByRole("switch", { name: "Discord channel" })).toBeDisabled(); fireEvent.change(screen.getByPlaceholderText("Discord bot token"), { target: { value: "discord-token" }, }); fireEvent.click(screen.getByText("Advanced")); fireEvent.change(screen.getByLabelText("Allowed channels"), { target: { value: "123, 456" }, }); fireEvent.click(within(screen.getByRole("radiogroup", { name: "Group behavior" })).getByRole( "radio", { name: "All messages" }, )); fireEvent.click(screen.getByRole("button", { name: "Check and enable" })); await waitFor(() => expect( requestMutationMock.mock.calls.some(([action]) => ( action === "settings.channel.configure" )), ).toBe(true), ); expect(requestMutationMock).toHaveBeenCalledWith( "settings.channel.configure", { name: "discord", enable: true, values: { "channels.discord.token": "discord-token", "channels.discord.allowChannels": "123, 456", "channels.discord.groupPolicy": "open", }, }, 150_000, ); expect(await screen.findByText("Checked and enabled.")).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Discord channel" })).toHaveAttribute( "aria-checked", "true", ); }); it("prefills saved channel config without exposing secrets", 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "discord", display_name: "Discord", webui: "webui/index.ts", type: "channel", enabled: false, configured: true, installed: true, ready: false, status: "not_enabled", install_supported: true, requires_restart: true, config_values: { "channels.discord.allowChannels": "123, 456", "channels.discord.groupPolicy": "open", }, configured_fields: [ "channels.discord.token", "channels.discord.allowChannels", "channels.discord.groupPolicy", ], setup: channelSetupContract("discord"), }], enabled_count: 0, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce({ features: [{ name: "discord", display_name: "Discord", webui: "webui/index.ts", type: "channel", enabled: true, configured: true, installed: true, ready: true, status: "enabled", install_supported: true, requires_restart: true, setup: channelSetupContract("discord"), }], enabled_count: 1, requires_restart: false, }); renderSettingsView({ initialSection: "channels" }); expect(await screen.findByRole("button", { name: "View Discord settings" })).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Discord channel" })).toHaveAttribute( "aria-checked", "false", ); expect(screen.getByRole("switch", { name: "Discord channel" })).toBeEnabled(); expect(screen.getByText("Configured manually")).toBeInTheDocument(); expect(screen.getByText("Saved")).toBeInTheDocument(); const savedSecret = screen.getByPlaceholderText("Saved secret"); expect(savedSecret).toHaveValue(""); expect(savedSecret).toHaveAttribute("autocomplete", "off"); expect(savedSecret.closest("form")).not.toBeNull(); expect(screen.queryByDisplayValue("discord-secret-token")).not.toBeInTheDocument(); fireEvent.click(screen.getByText("Advanced")); expect(screen.getByLabelText("Allowed channels")).toHaveValue("123, 456"); expect(within(screen.getByRole("radiogroup", { name: "Group behavior" })).getByRole( "radio", { name: "All messages" }, )).toHaveAttribute("aria-checked", "true"); fireEvent.click(screen.getByRole("switch", { name: "Discord channel" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.feature.enable", { name: "discord" }, 150_000, ), ); }); it("shows an actionable credential guide for Telegram", 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 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "telegram", display_name: "Telegram", webui: "webui/index.ts", type: "channel", enabled: false, installed: true, ready: false, status: "not_enabled", install_supported: true, requires_restart: true, }], enabled_count: 0, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }), ); renderSettingsView({ initialSection: "channels" }); expect(await screen.findByRole("button", { name: "View Telegram settings" })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Open Telegram setup" })).toHaveAttribute( "href", "https://nanobot.wiki/docs/0.2.2/getting-started/chat-apps#telegram", ); }); it("shows branded setup guide links for supported WebUI channels", async () => { const channels = [ ["websocket", "WebSocket", "Open WebSocket setup"], ["telegram", "Telegram", "Open Telegram setup"], ["feishu", "Feishu", "Open Feishu setup"], ["slack", "Slack", "Open Slack setup"], ["discord", "Discord", "Open Discord setup"], ["email", "Email", "Open Email setup"], ["matrix", "Matrix", "Open Matrix setup"], ["whatsapp", "WhatsApp", "Open WhatsApp setup"], ["dingtalk", "DingTalk", "Open DingTalk setup"], ["wecom", "WeCom", "Open WeCom setup"], ["weixin", "WeChat", "Open WeChat setup"], ["qq", "QQ", "Open QQ setup"], ["signal", "Signal", "Open Signal setup"], ["msteams", "Microsoft Teams", "Open Teams setup"], ["napcat", "NapCat", "Open NapCat setup"], ] as const; const hiddenChannels = [["mochat", "MoChat"]] as const; 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 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: channels.map(([name, displayName]) => ({ name, display_name: displayName, webui: ["feishu", "weixin"].includes(name) ? "webui/index.tsx" : "webui/index.ts", type: "channel", enabled: name === "websocket", installed: true, ready: name === "websocket", status: name === "websocket" ? "enabled" : "not_enabled", install_supported: true, requires_restart: true, })).concat(hiddenChannels.map(([name, displayName]) => ({ name, display_name: displayName, settings_visible: false, type: "channel", enabled: false, installed: true, ready: false, status: "not_enabled", install_supported: true, requires_restart: true, }))), enabled_count: 1, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }), ); renderSettingsView({ initialSection: "channels" }); for (const [, displayName, guideLabel] of channels) { fireEvent.click(await screen.findByRole("button", { name: `View ${displayName} settings` })); if (displayName === "Feishu") { fireEvent.click(screen.getByRole("button", { name: "nanobot" })); } const guide = screen.getByRole("link", { name: guideLabel }); expect(guide).toHaveAttribute("href", expect.stringMatching(/^https:\/\//)); expect(guide.querySelector("span[aria-hidden] img, span[aria-hidden] svg")).not.toBeNull(); } expect(screen.queryByRole("button", { name: "View MoChat settings" })).not.toBeInTheDocument(); }); it("uses choices for channel enum and boolean fields", 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 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: ["email", "feishu", "matrix", "qq"].map((name) => ({ name, display_name: name === "qq" ? "QQ" : name[0].toUpperCase() + name.slice(1), webui: name === "feishu" ? "webui/index.tsx" : "webui/index.ts", type: "channel", enabled: true, installed: true, ready: true, status: "enabled", install_supported: true, requires_restart: true, setup: channelSetupContract(name as "email" | "feishu" | "matrix" | "qq"), })), enabled_count: 4, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }), ); renderSettingsView({ initialSection: "channels" }); fireEvent.click(await screen.findByRole("button", { name: "View Email settings" })); const consent = screen.getByRole("radiogroup", { name: "Consent granted" }); expect(within(consent).getByRole("radio", { name: "Not granted" })).toHaveAttribute( "aria-checked", "true", ); expect(within(consent).getByRole("radio", { name: "Granted" })).toBeInTheDocument(); expect(screen.queryByPlaceholderText("true")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "View Feishu settings" })); fireEvent.click(screen.getByRole("button", { name: "nanobot" })); fireEvent.click(screen.getByText("Advanced")); const region = screen.getByRole("radiogroup", { name: "Region" }); expect(within(region).getByRole("radio", { name: "Feishu" })).toHaveAttribute( "aria-checked", "true", ); expect(within(region).getByRole("radio", { name: "Lark" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "View Matrix settings" })); fireEvent.click(screen.getByText("Advanced")); const matrixBehavior = screen.getByRole("radiogroup", { name: "Group behavior" }); expect(within(matrixBehavior).getByRole("radio", { name: "All messages" })).toHaveAttribute( "aria-checked", "true", ); expect(within(matrixBehavior).getByRole("radio", { name: "Allowlist" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "View QQ settings" })); fireEvent.click(screen.getByText("Advanced")); const format = screen.getByRole("radiogroup", { name: "Message format" }); expect(within(format).getByRole("radio", { name: "Plain text" })).toHaveAttribute( "aria-checked", "true", ); expect(within(format).getByRole("radio", { name: "Markdown" })).toBeInTheDocument(); }); it("does not offer to disable the websocket channel", 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: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [{ name: "websocket", display_name: "Websocket", capabilities: ["always_enabled"], webui: "webui/index.ts", type: "channel", enabled: true, installed: true, ready: true, status: "enabled", install_supported: true, requires_restart: true, }], enabled_count: 1, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); renderSettingsView({ initialSection: "channels" }); expect(await screen.findByRole("button", { name: "View WebSocket settings" })).toBeInTheDocument(); expect(screen.getAllByText("WebSocket")).toHaveLength(2); expect(screen.queryByText("Required for WebUI")).not.toBeInTheDocument(); expect(screen.getAllByText("On").length).toBeGreaterThanOrEqual(1); expect(screen.getByText("Managed by WebUI")).toBeInTheDocument(); expect(screen.queryByText("Configured manually")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Disable channel" })).not.toBeInTheDocument(); const websocketSwitch = screen.getByRole("switch", { name: "WebSocket channel" }); expect(websocketSwitch).toBeDisabled(); expect(websocketSwitch).toHaveAttribute("aria-checked", "true"); expect(requestMutationMock).not.toHaveBeenCalled(); }); 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)); }); it("does not keep Apps loading while an empty CLI catalog refresh is pending", 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, catalog_updated_at: null, catalog_refresh_pending: true, }); } if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }), ); renderSettingsView(); expect(await screen.findByText("No apps available.")).toBeInTheDocument(); expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Browse MCP tools" })); expect(await screen.findByText("Add MCP server")).toBeInTheDocument(); }); it("shows token activity on the overview", async () => { const payload: SettingsPayload = { ...settingsPayload(), usage: { days: [ { date: "2026-06-03", prompt_tokens: 1200, completion_tokens: 300, cached_tokens: 500, total_tokens: 1500, requests: 2, }, ], total_tokens: 1500, total_tokens_30d: 1500, total_tokens_365d: 1500, peak_day_tokens: 1500, current_streak_days: 1, longest_streak_days: 1, active_days_30d: 1, requests_30d: 2, updated_at: "2026-06-03T00:00:00Z", }, }; 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: "overview" }); expect(await screen.findByLabelText("Token activity")).toBeInTheDocument(); expect(screen.getByText("Token Usage")).toBeInTheDocument(); expect(screen.queryByText("Token activity")).not.toBeInTheDocument(); expect(screen.queryByText("Total tokens")).not.toBeInTheDocument(); expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument(); }); it("coalesces focus refreshes while usage is already loading", async () => { const payload: SettingsPayload = { ...settingsPayload(), usage: { days: [], total_tokens: 0, total_tokens_30d: 0, total_tokens_365d: 0, peak_day_tokens: 0, current_streak_days: 0, longest_streak_days: 0, active_days_30d: 0, requests_30d: 0, updated_at: null, }, }; let resolveUsage!: (response: Response) => void; const pendingUsage = new Promise((resolve) => { resolveUsage = resolve; }); const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(payload); if (url === "/api/settings/usage") return pendingUsage; return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); renderSettingsView({ initialSection: "overview", initialSettings: payload }); await waitFor(() => { expect(fetchMock.mock.calls.filter(([input]) => ( String(input) === "/api/settings/usage" ))).toHaveLength(1); }); window.dispatchEvent(new Event("focus")); window.dispatchEvent(new Event("focus")); expect(fetchMock.mock.calls.filter(([input]) => ( String(input) === "/api/settings/usage" ))).toHaveLength(1); await act(async () => { resolveUsage(jsonResponse(payload.usage)); await pendingUsage; }); }); it("aligns token activity days with the configured timezone", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-02T18:00:00Z")); const basePayload = settingsPayload(); const payload: SettingsPayload = { ...basePayload, agent: { ...basePayload.agent, timezone: "Asia/Shanghai", }, usage: { days: [ { date: "2026-06-03", prompt_tokens: 1200, completion_tokens: 300, cached_tokens: 500, total_tokens: 1500, requests: 2, }, ], total_tokens: 1500, total_tokens_30d: 1500, total_tokens_365d: 1500, peak_day_tokens: 1500, current_streak_days: 1, longest_streak_days: 1, active_days_30d: 1, requests_30d: 2, updated_at: "2026-06-03T00:00:00Z", }, }; vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); renderSettingsView({ initialSection: "overview", initialSettings: payload }); expect(screen.getByLabelText("2026-06-03: 1.5K tokens, 2 requests")).toBeInTheDocument(); }); it("keeps generation parameters collapsed until advanced options are opened", 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(screen.queryByText("Edit preset")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Advanced options/ })).not.toBeInTheDocument(); await togglePresetEditor(); const advanced = await screen.findByRole("button", { name: /Advanced options/ }); expect(screen.queryByText("Context window")).not.toBeInTheDocument(); expect(screen.queryByText("Temperature")).not.toBeInTheDocument(); fireEvent.click(advanced); expect(await screen.findByText("Context window")).toBeInTheDocument(); expect(screen.getByText("Temperature")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "64K" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "200K" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "500K" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "1M" })).toBeInTheDocument(); const reasoningEffort = screen.getByLabelText("Reasoning effort"); expect(reasoningEffort).toHaveProperty("type", "text"); fireEvent.change(reasoningEffort, { target: { value: "provider-native-mode" } }); expect(reasoningEffort).toHaveValue("provider-native-mode"); }); it("expands the model preset editor directly below the selected row", 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" }); const row = await screen.findByTestId("model-call-order-row-primary"); const trigger = within(row).getAllByRole("button")[0]; expect(screen.queryByTestId("model-preset-editor")).not.toBeInTheDocument(); expect(trigger).toHaveAttribute("aria-expanded", "false"); fireEvent.click(trigger); const editor = screen.getByTestId("model-preset-editor"); expect(trigger).toHaveAttribute("aria-pressed", "true"); expect(trigger).toHaveAttribute("aria-expanded", "true"); expect(trigger).toHaveAttribute("aria-controls", "model-preset-editor"); expect(row.parentElement).toHaveAttribute("role", "listitem"); expect(row.parentElement?.parentElement).toHaveAttribute("role", "list"); expect(row.nextElementSibling).toBe(editor); expect(editor).toHaveClass( "slide-in-from-top-1", "lg:max-w-6xl", "rounded-[18px]", ); expect(within(editor).getByDisplayValue("Primary")).toBeInTheDocument(); const deleteButton = within(editor).getByRole("button", { name: "Delete" }); expect(deleteButton).toBeDisabled(); expect(deleteButton).toHaveAttribute("aria-describedby", "model-preset-delete-hint"); expect( within(editor).getByText("Remove this preset from the call order before deleting it."), ).toBeInTheDocument(); fireEvent.click(trigger); expect(trigger).toHaveAttribute("aria-expanded", "false"); expect(screen.queryByTestId("model-preset-editor")).not.toBeInTheDocument(); }); it("drags model presets to reorder and saves the model call order immediately", async () => { const { payload, backupPreset } = settingsPayloadWithBackup(); const updatedPayload: SettingsPayload = { ...payload, agent: { ...payload.agent, model: backupPreset.model, provider: backupPreset.provider, resolved_provider: backupPreset.resolved_provider, model_preset: backupPreset.name, }, model_presets: payload.model_presets.map((preset) => ({ ...preset, active: preset.name === backupPreset.name, })), model_call_order: ["backup", "primary"], }; 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 }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce(updatedPayload); renderSettingsView({ initialSection: "models", initialSettings: payload }); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( "/api/settings", expect.objectContaining({ headers: { Authorization: "Bearer tok" }, }), ), ); await togglePresetEditor(); fireEvent.change(screen.getByDisplayValue("Primary"), { target: { value: "Primary draft" }, }); const primaryRow = screen.getByTestId("model-call-order-row-primary"); const backupRow = screen.getByTestId("model-call-order-row-backup"); expect(backupRow).toHaveAttribute("draggable", "true"); expect(screen.queryByRole("button", { name: "Move up" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Move down" })).not.toBeInTheDocument(); const dataTransfer = { dropEffect: "move", effectAllowed: "move", setData: vi.fn(), }; fireEvent.dragStart(backupRow, { dataTransfer }); fireEvent.dragEnter(primaryRow, { dataTransfer }); fireEvent.drop(primaryRow, { dataTransfer }); await waitFor(() => { expect(requestMutationMock).toHaveBeenCalledWith( "settings.model_call_order.update", { order: ["backup", "primary"] }, 20_000, ); }); expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument(); expect(screen.getByDisplayValue("Primary draft")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled(); }); it("keeps repeated fallback preset rows stable when changing the primary preset", async () => { const { payload, backupPreset } = settingsPayloadWithBackup(); const initialPayload: SettingsPayload = { ...payload, agent: { ...payload.agent, model: backupPreset.model, provider: backupPreset.provider, resolved_provider: backupPreset.resolved_provider, model_preset: backupPreset.name, }, model_presets: payload.model_presets.map((preset) => ({ ...preset, active: preset.name === backupPreset.name, })), model_call_order: ["backup", "primary", "backup"], }; const updatedPayload: SettingsPayload = { ...initialPayload, agent: { ...payload.agent, model_preset: "primary", }, model_presets: payload.model_presets.map((preset) => ({ ...preset, active: preset.name === "primary", })), model_call_order: ["primary", "backup", "backup"], }; const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(initialPayload); 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; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce(updatedPayload); renderSettingsView({ initialSection: "models", initialSettings: initialPayload }); const primaryRow = screen.getByTestId("model-call-order-row-primary"); const backupRows = screen.getAllByTestId("model-call-order-row-backup"); const firstBackupRow = backupRows[0]; const secondBackupRow = backupRows[1]; const secondBackupTrigger = within(secondBackupRow).getAllByRole("button")[0]; fireEvent.click(secondBackupTrigger); expect(screen.getAllByTestId("model-preset-editor")).toHaveLength(1); expect(secondBackupRow.nextElementSibling).toBe( screen.getByTestId("model-preset-editor"), ); fireEvent.click(secondBackupTrigger); const dataTransfer = { dropEffect: "move", effectAllowed: "move", setData: vi.fn(), }; fireEvent.dragStart(primaryRow, { dataTransfer }); fireEvent.dragEnter(firstBackupRow, { dataTransfer }); fireEvent.drop(firstBackupRow, { dataTransfer }); await waitFor(() => expect( screen .getAllByTestId(/^model-call-order-row-/) .map((row) => row.getAttribute("data-testid")), ).toEqual([ "model-call-order-row-primary", "model-call-order-row-backup", "model-call-order-row-backup", ]), ); }); it("restores the model call order when immediate persistence fails", async () => { const { payload } = settingsPayloadWithBackup(); 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 }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockRejectedValueOnce(new Error("Order update failed")); renderSettingsView({ initialSection: "models", initialSettings: payload }); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( "/api/settings", expect.objectContaining({ headers: { Authorization: "Bearer tok" }, }), ), ); fireEvent.keyDown(screen.getByTestId("model-call-order-row-backup"), { key: "ArrowUp", }); expect(await screen.findByText("Order update failed")).toBeInTheDocument(); expect( screen .getAllByTestId(/^model-call-order-row-/) .map((row) => row.getAttribute("data-testid")), ).toEqual([ "model-call-order-row-primary", "model-call-order-row-backup", ]); }); it("shows presets outside the call order in the unified list and adds them directly", async () => { const { payload } = settingsPayloadWithBackup(); const codexPreset = { ...payload.model_presets[0], name: "codex", label: "Codex", active: false, model: "openai-codex/gpt-5.5", provider: "openai", resolved_provider: "openai", }; const payloadWithCodex: SettingsPayload = { ...payload, model_presets: [...payload.model_presets, codexPreset], }; const orderedPayload: SettingsPayload = { ...payloadWithCodex, model_call_order: ["primary", "backup", "codex"], }; const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(payloadWithCodex); 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; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce(orderedPayload); renderSettingsView({ initialSection: "models", initialSettings: payloadWithCodex }); const codexRow = await screen.findByTestId("model-call-order-row-codex"); expect(codexRow).toHaveTextContent("Codex"); expect(codexRow).toHaveTextContent("openai-codex/gpt-5.5"); expect(codexRow).toHaveTextContent("Disabled"); expect(codexRow).toHaveAttribute("draggable", "false"); expect(screen.queryByRole("button", { name: "Add preset" })).not.toBeInTheDocument(); const enableSwitch = within(codexRow).getByRole("switch", { name: "Enable preset" }); expect(enableSwitch).not.toBeChecked(); fireEvent.click(enableSwitch); await waitFor(() => { expect(requestMutationMock).toHaveBeenCalledWith( "settings.model_call_order.update", { order: ["primary", "backup", "codex"] }, 20_000, ); }); const enabledCodexRow = await screen.findByTestId("model-call-order-row-codex"); expect(enabledCodexRow).not.toHaveTextContent("Disabled"); expect(enabledCodexRow).not.toHaveTextContent(/Fallback/); expect(enabledCodexRow).toHaveAttribute("draggable", "true"); expect( within(enabledCodexRow).getByRole("switch", { name: "Disable preset" }), ).toBeChecked(); expect(screen.queryByText("Up to date.")).not.toBeInTheDocument(); }); it("appends a new model preset to the call order immediately", async () => { const { payload } = settingsPayloadWithBackup(); const writerPreset = { ...payload.model_presets[0], name: "writer", label: "Writer", active: false, model: "openai/gpt-4o-mini", provider: "openai", resolved_provider: "openai", }; const createdPayload: SettingsPayload = { ...payload, model_presets: [...payload.model_presets, writerPreset], created_model_preset: writerPreset.name, }; const orderedPayload: SettingsPayload = { ...createdPayload, model_call_order: ["primary", "backup", "writer"], created_model_preset: undefined, }; 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 }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock .mockResolvedValueOnce(createdPayload) .mockResolvedValueOnce(orderedPayload); renderSettingsView({ initialSection: "models", initialSettings: payload }); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( "/api/settings", expect.objectContaining({ headers: { Authorization: "Bearer tok" }, }), ), ); fireEvent.click(screen.getByRole("button", { name: "New model preset" })); expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Save preset" })).toBeDisabled(); expect( screen.queryByText("Complete the preset before saving."), ).not.toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText("Fast writing"), { target: { value: "Writer" }, }); await openPopover(screen.getByRole("button", { name: "Select model" })); const modelSearch = await screen.findByRole("combobox", { name: "Search or type model ID", }); fireEvent.change(modelSearch, { target: { value: "openai/gpt-4o-mini" }, }); fireEvent.keyDown(modelSearch, { key: "Enter" }); const saveButton = screen.getByRole("button", { name: "Save preset" }); expect(saveButton).toBeEnabled(); fireEvent.click(saveButton); await waitFor(() => { expect(requestMutationMock).toHaveBeenLastCalledWith( "settings.model_call_order.update", { order: ["primary", "backup", "writer"] }, 20_000, ); }); const writerRow = await screen.findByTestId("model-call-order-row-writer"); expect(writerRow).not.toHaveTextContent("Disabled"); expect(writerRow).not.toHaveTextContent(/Fallback/); expect(within(writerRow).getByRole("switch", { name: "Disable preset" })).toBeChecked(); expect(screen.getByDisplayValue("Writer")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument(); }); it("converts legacy model settings into presets before editing call order", async () => { const migratedPayload = settingsPayload(); const defaultPreset = { ...migratedPayload.model_presets[0], name: "default", label: "Default", is_default: true, }; const legacyPayload: SettingsPayload = { ...migratedPayload, agent: { ...migratedPayload.agent, model_preset: "default", }, model_presets: [defaultPreset], model_call_order: [], model_call_order_editable: false, }; const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(legacyPayload); 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; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce(migratedPayload); renderSettingsView({ initialSection: "models", initialSettings: legacyPayload }); fireEvent.click( await screen.findByRole("button", { name: "Convert to presets" }), ); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.model_configuration.migrate", {}, 20_000, ), ); expect( screen.queryByRole("button", { name: "Convert to presets" }), ).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument(); expect(screen.queryByText("Default")).not.toBeInTheDocument(); }); it("signs in to the xAI Grok provider", async () => { const base = settingsPayload(); const xaiProvider = { name: "xai_grok", label: "xAI Grok", configured: false, auth_type: "oauth" as const, api_key_required: false, api_key_hint: null, api_base: null, default_api_base: "https://cli-chat-proxy.grok.com/v1", model_catalog: "builtin", oauth_account: null, oauth_expires_at: null, oauth_login_supported: true, }; const payload: SettingsPayload = { ...base, providers: [xaiProvider] }; const signedIn: SettingsPayload = { ...payload, providers: [{ ...xaiProvider, configured: true, oauth_account: "user@example.com" }], }; const authorization = { status: "authorization_required", provider: "xai_grok", flow_id: "flow-123", authorization_url: "https://auth.x.ai/oauth2/authorize?state=test", expires_in: 600, }; 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 }); } return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); requestMutationMock .mockResolvedValueOnce(authorization) .mockResolvedValueOnce(signedIn); const popup = { opener: window, location: { href: "about:blank" }, close: vi.fn(), }; vi.stubGlobal("open", vi.fn(() => popup)); renderSettingsView({ initialSection: "models", initialSettings: payload }); await chooseProviderToConfigure("xAI Grok"); fireEvent.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.provider.oauth_login", { provider: "xai_grok" }, 20_000, ), ); expect(popup.opener).toBeNull(); expect(popup.location.href).toBe(authorization.authorization_url); expect( screen.getByText( "Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, paste the authorization code below.", ), ).toBeInTheDocument(); const callbackInput = await screen.findByRole("textbox", { name: "Authorization code", }); fireEvent.change(callbackInput, { target: { value: "secret" }, }); fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.provider.oauth_complete", { provider: "xai_grok", flow_id: "flow-123", authorization_response: "secret", }, 20_000, ), ); expect(await screen.findByText("Signed in as user@example.com")).toBeInTheDocument(); }); it("recognizes remote access before starting xAI Grok sign-in", async () => { const happyWindow = window as typeof window & { happyDOM: { setURL: (url: string) => void }; }; const originalUrl = window.location.href; happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models"); try { const base = settingsPayload(); const xaiProvider = { name: "xai_grok", label: "xAI Grok", configured: false, auth_type: "oauth" as const, api_key_required: false, api_key_hint: null, api_base: null, default_api_base: "https://cli-chat-proxy.grok.com/v1", model_catalog: "builtin", oauth_account: null, oauth_expires_at: null, oauth_login_supported: true, }; const payload: SettingsPayload = { ...base, providers: [xaiProvider] }; const authorization = { status: "authorization_required", provider: "xai_grok", flow_id: "flow-remote", authorization_url: "https://auth.x.ai/oauth2/authorize?state=remote", expires_in: 600, }; 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 }); } return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce(authorization); const popup = { opener: window, location: { href: "about:blank" }, close: vi.fn(), }; const openMock = vi.fn(() => popup); vi.stubGlobal("open", openMock); renderSettingsView({ initialSection: "models", initialSettings: payload }); await chooseProviderToConfigure("xAI Grok"); expect( screen.getByText( "Select Sign in to open xAI on your computer, then paste the authorization code shown after login.", ), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Sign in" })); const dialog = await screen.findByRole("dialog"); expect(openMock).not.toHaveBeenCalled(); expect( within(dialog).getByText( "Select Sign in to open xAI on your computer. After signing in, paste the authorization code shown by xAI below.", ), ).toBeInTheDocument(); expect( within(dialog).queryByRole("textbox", { name: "xAI sign-in URL" }), ).not.toBeInTheDocument(); expect( within(dialog).queryByRole("button", { name: "Copy" }), ).not.toBeInTheDocument(); expect( within(dialog).getByRole("textbox", { name: "Authorization code" }), ).toBeInTheDocument(); fireEvent.click(within(dialog).getByRole("button", { name: "Sign in" })); expect(openMock).toHaveBeenCalledWith( authorization.authorization_url, "_blank", "noopener,noreferrer", ); expect(popup.opener).toBeNull(); } finally { happyWindow.happyDOM.setURL(originalUrl); } }); it("polls local OpenAI Codex sign-in until the loopback callback completes", async () => { const base = settingsPayload(); const codexProvider = { name: "openai_codex", label: "OpenAI Codex", configured: false, auth_type: "oauth" as const, api_key_required: false, api_key_hint: null, api_base: null, default_api_base: "https://chatgpt.com/backend-api", model_catalog: "builtin", oauth_account: null, oauth_expires_at: null, oauth_login_supported: true, }; const payload: SettingsPayload = { ...base, providers: [codexProvider] }; const signedIn: SettingsPayload = { ...payload, providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }], }; const authorization = { status: "authorization_required", provider: "openai_codex", flow_id: "flow-codex-local", authorization_url: "https://auth.openai.com/oauth/authorize?state=local", expires_in: 600, completion_input: "callback_url", }; 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 }); } return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); requestMutationMock .mockResolvedValueOnce(authorization) .mockResolvedValueOnce(signedIn); const openMock = vi.fn(); vi.stubGlobal("open", openMock); renderSettingsView({ initialSection: "models", initialSettings: payload }); await chooseProviderToConfigure("OpenAI Codex"); fireEvent.click(screen.getByRole("button", { name: "Sign in" })); const dialog = await screen.findByRole("dialog"); expect(requestMutationMock).toHaveBeenCalledWith( "settings.provider.oauth_login", { provider: "openai_codex" }, 20_000, ); expect(openMock).not.toHaveBeenCalled(); expect( within(dialog).getByText( "Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, copy the full localhost callback URL from the address bar and paste it below.", ), ).toBeInTheDocument(); expect(within(dialog).getByText("Waiting for the browser callback…")).toBeInTheDocument(); expect( within(dialog).queryByText("Paste the callback URL to continue."), ).not.toBeInTheDocument(); expect( await screen.findByText("Signed in as acct-codex", {}, { timeout: 2500 }), ).toBeInTheDocument(); }); it("completes remote OpenAI Codex sign-in with the full callback URL", async () => { const happyWindow = window as typeof window & { happyDOM: { setURL: (url: string) => void }; }; const originalUrl = window.location.href; happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models"); try { const base = settingsPayload(); const codexProvider = { name: "openai_codex", label: "OpenAI Codex", configured: false, auth_type: "oauth" as const, api_key_required: false, api_key_hint: null, api_base: null, default_api_base: "https://chatgpt.com/backend-api", model_catalog: "builtin", oauth_account: null, oauth_expires_at: null, oauth_login_supported: true, }; const payload: SettingsPayload = { ...base, providers: [codexProvider] }; const signedIn: SettingsPayload = { ...payload, providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }], }; const authorization = { status: "authorization_required", provider: "openai_codex", flow_id: "flow-codex", authorization_url: "https://auth.openai.com/oauth/authorize?state=test", expires_in: 600, completion_input: "callback_url", }; const callbackUrl = "http://localhost:1455/auth/callback?code=secret&state=test"; 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 }); } return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockImplementation(async ( action: string, mutationPayload: Record, ) => { if (action === "settings.provider.oauth_login") return authorization; if (mutationPayload.authorization_response === callbackUrl) return signedIn; return { status: "pending", provider: "openai_codex", flow_id: "flow-codex", }; }); const popup = { opener: window, location: { href: "about:blank" }, close: vi.fn(), }; const openMock = vi.fn(() => popup); vi.stubGlobal("open", openMock); renderSettingsView({ initialSection: "models", initialSettings: payload }); await chooseProviderToConfigure("OpenAI Codex"); expect( screen.getByText( "Sign in through this browser, then paste the full localhost callback URL back into nanobot.", ), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Sign in" })); const dialog = await screen.findByRole("dialog"); expect(openMock).not.toHaveBeenCalled(); expect( within(dialog).getByText( "Open ChatGPT in this browser and finish signing in. When the localhost page fails to load, copy the full URL from the address bar and paste it below.", ), ).toBeInTheDocument(); expect(within(dialog).getByText("Paste the callback URL to continue.")).toBeInTheDocument(); const callbackInput = within(dialog).getByRole("textbox", { name: "Full callback URL", }); expect(callbackInput).toHaveAttribute( "placeholder", "http://localhost:1455/auth/callback?code=…&state=…", ); fireEvent.click(within(dialog).getByRole("button", { name: "Open ChatGPT" })); expect(openMock).toHaveBeenCalledWith( authorization.authorization_url, "_blank", "noopener,noreferrer", ); expect(popup.opener).toBeNull(); fireEvent.change(callbackInput, { target: { value: callbackUrl } }); fireEvent.click(within(dialog).getByRole("button", { name: "Finish sign-in" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.provider.oauth_complete", { provider: "openai_codex", flow_id: "flow-codex", authorization_response: callbackUrl, }, 20_000, ), ); expect(await screen.findByText("Signed in as acct-codex")).toBeInTheDocument(); } finally { happyWindow.happyDOM.setURL(originalUrl); } }); it("saves scoped proxies for xAI and OpenAI Codex OAuth providers", async () => { const base = settingsPayload(); const providers: SettingsPayload["providers"] = [ { name: "xai_grok", label: "xAI Grok", configured: false, auth_type: "oauth", api_key_required: false, api_key_hint: null, api_base: null, default_api_base: "https://cli-chat-proxy.grok.com/v1", model_catalog: "builtin", oauth_account: null, oauth_expires_at: null, oauth_login_supported: true, proxy: "http://127.0.0.1:7000", advanced_fields: ["extra_body", "proxy"], extra_body: null, }, { name: "openai_codex", label: "OpenAI Codex", configured: false, auth_type: "oauth", api_key_required: false, api_key_hint: null, api_base: null, default_api_base: "https://chatgpt.com/backend-api", model_catalog: "builtin", oauth_account: null, oauth_expires_at: null, oauth_login_supported: true, proxy: null, advanced_fields: ["extra_body", "proxy"], extra_body: null, }, ]; let payload: SettingsPayload = { ...base, providers }; 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 }); } return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockImplementation(async ( _action: string, values: { provider?: string; proxy?: string; extraBody?: string }, ) => { payload = { ...payload, providers: payload.providers.map((provider) => provider.name === values.provider ? { ...provider, proxy: values.proxy || null, extra_body: values.extraBody ? JSON.parse(values.extraBody) : null, } : provider, ), }; return payload; }); renderSettingsView({ initialSection: "models", initialSettings: payload }); await chooseProviderToConfigure("xAI Grok"); fireEvent.click(screen.getByRole("button", { name: "Advanced options" })); const xaiProxy = screen.getByLabelText("Network proxy"); expect(xaiProxy).toHaveValue("http://127.0.0.1:7000"); fireEvent.change(xaiProxy, { target: { value: "http://127.0.0.1:7890" } }); expect(screen.getByRole("button", { name: "Sign in" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Sign in" })).toHaveAttribute( "title", "Save advanced changes before signing in.", ); fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.provider.update", { provider: "xai_grok", extraBody: "", proxy: "http://127.0.0.1:7890", }, 20_000, ), ); await waitFor(() => expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled()); fireEvent.click(screen.getByRole("button", { name: "xAI Grok" })); await chooseProviderToConfigure("OpenAI Codex"); fireEvent.click(screen.getByRole("button", { name: "Advanced options" })); const codexProxy = screen.getByLabelText("Network proxy"); expect(codexProxy).toHaveValue(""); fireEvent.change(codexProxy, { target: { value: "http://proxy.example:8080" } }); fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.provider.update", { provider: "openai_codex", extraBody: "", proxy: "http://proxy.example:8080", }, 20_000, ), ); }); it("maps provider request switches to raw extraBody fields", async () => { const base = settingsPayload(); const providers: SettingsPayload["providers"] = [ { name: "xai_grok", label: "xAI Grok", configured: true, auth_type: "oauth", api_key_required: false, oauth_account: "grok@example.com", oauth_login_supported: true, advanced_fields: ["extra_body", "proxy"], extra_body: null, }, { name: "openai_codex", label: "OpenAI Codex", configured: true, auth_type: "oauth", api_key_required: false, oauth_account: "codex@example.com", oauth_login_supported: true, advanced_fields: ["extra_body", "proxy"], extra_body: null, }, { name: "deepseek", label: "DeepSeek", configured: true, api_key_required: true, api_key_hint: "deep••••test", api_base: "https://api.deepseek.com", advanced_fields: ["extra_body"], extra_body: null, }, { name: "openai", label: "OpenAI", configured: true, api_key_required: true, api_key_hint: "sk-••••test", api_base: "https://api.openai.com/v1", api_type: "auto", advanced_fields: ["api_type", "extra_body"], extra_body: null, }, ]; const payload: SettingsPayload = { ...base, providers }; 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 }); } return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValue(payload); renderSettingsView({ initialSection: "models", initialSettings: payload }); fireEvent.click(screen.getByRole("button", { name: "xAI Grok" })); const xSearch = screen.getByRole("switch", { name: "X Search" }); expect(xSearch).toHaveAttribute("aria-checked", "true"); fireEvent.click(xSearch); fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.provider.update", expect.objectContaining({ provider: "xai_grok" }), 20_000, )); await waitFor(() => expect( screen.getByRole("button", { name: "Save provider" }), ).toBeEnabled()); fireEvent.click(screen.getByRole("button", { name: "xAI Grok" })); fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" })); fireEvent.click(screen.getByRole("switch", { name: "Fast mode" })); fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.provider.update", expect.objectContaining({ provider: "openai_codex" }), 20_000, )); await waitFor(() => expect( screen.getByRole("button", { name: "Save provider" }), ).toBeEnabled()); fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" })); fireEvent.click(screen.getByRole("button", { name: /^DeepSeek/ })); expect(screen.getByText(/DeepSeek V4 Flash/)).toBeInTheDocument(); const deepSeekSearch = screen.getByRole("switch", { name: "DeepSeek web search" }); expect(deepSeekSearch).toHaveAttribute("aria-checked", "true"); fireEvent.click(deepSeekSearch); fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => expect( screen.queryByRole("switch", { name: "DeepSeek web search" }), ).not.toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: /^OpenAI https:/ })); fireEvent.click(screen.getByRole("switch", { name: "OpenAI web search" })); fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => expect( screen.queryByRole("switch", { name: "OpenAI web search" }), ).not.toBeInTheDocument()); await waitFor(() => { const requestUpdates = requestMutationMock.mock.calls .filter(([action]) => action === "settings.provider.update") .map(([, values]) => { const update = values as { provider: string; apiType?: string; extraBody?: string; }; return [update.provider, { ...(update.apiType ? { apiType: update.apiType } : {}), extraBody: JSON.parse(update.extraBody ?? "{}"), }] as const; }); expect(requestUpdates).toEqual([ ["xai_grok", { extraBody: { tools: [] } }], ["openai_codex", { extraBody: { service_tier: "priority" } }], ["deepseek", { extraBody: { tools: [] } }], ["openai", { apiType: "responses", extraBody: { tools: [{ type: "web_search" }] }, }], ]); }); }); it("recognizes and removes versioned web search tools without losing raw settings", async () => { const base = settingsPayload(); const payload: SettingsPayload = { ...base, providers: [{ name: "openai", label: "OpenAI", configured: true, api_key_required: true, api_key_hint: "sk-••••test", api_base: "https://api.openai.com/v1", api_type: "auto", advanced_fields: ["api_type", "extra_body"], extra_body: { metadata: { owner: "legacy-config" }, tools: [ { type: "web_search_preview", search_context_size: "medium" }, { type: "file_search", vector_store_ids: ["vs_legacy"] }, ], }, }], }; 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 }); } return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce(payload); renderSettingsView({ initialSection: "models", initialSettings: payload }); fireEvent.click(await screen.findByRole("button", { name: /^OpenAI https:/ })); const searchSwitch = screen.getByRole("switch", { name: "OpenAI web search" }); expect(searchSwitch).toHaveAttribute("aria-checked", "true"); fireEvent.click(searchSwitch); fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => { const updateCall = requestMutationMock.mock.calls.find( ([action]) => action === "settings.provider.update", ); expect(updateCall).toBeTruthy(); const values = updateCall?.[1] as { extraBody: string }; expect(JSON.parse(values.extraBody)).toEqual({ metadata: { owner: "legacy-config" }, tools: [{ type: "file_search", vector_store_ids: ["vs_legacy"] }], }); }); }); it("creates a custom provider with folded advanced request settings", async () => { const base = settingsPayload(); let payload: SettingsPayload = { ...base, providers: [ { name: "deepseek", label: "DeepSeek", configured: true, api_key_required: true, api_key_hint: "deep••••test", api_base: "https://api.deepseek.com", }, { name: "openrouter", label: "OpenRouter", configured: false, api_key_required: true, api_key_hint: null, api_base: null, default_api_base: "https://openrouter.ai/api/v1", }, ], }; 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 }); } return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockImplementationOnce(async ( _action: string, values: Record, ) => { payload = { ...payload, created_provider: "custom-company-gateway", providers: [ ...payload.providers, { name: "custom-company-gateway", label: values.name, is_custom: true, configured: true, api_key_required: false, api_key_hint: "sk-c••••pany", api_base: values.apiBase, default_api_base: null, advanced_fields: [ "extra_headers", "extra_body", "extra_query", "proxy", "thinking_style", ], extra_headers: JSON.parse(values.extraHeaders), extra_body: JSON.parse(values.extraBody), extra_query: JSON.parse(values.extraQuery), proxy: values.proxy, thinking_style: values.thinkingStyle, }, ], }; return payload; }); renderSettingsView({ initialSection: "models", initialSettings: payload }); fireEvent.pointerDown( screen.getByRole("button", { name: "Add your own model provider" }), ); const customOption = await screen.findByRole("menuitem", { name: "Custom provider" }); const openRouterOption = screen.getByRole("menuitem", { name: "OpenRouter" }); expect(customOption.querySelector("svg, img")).not.toBeNull(); expect(openRouterOption.querySelector("svg, img")).not.toBeNull(); fireEvent.click(customOption); expect( screen.queryByRole("button", { name: "Add your own model provider" }), ).not.toBeInTheDocument(); expect(screen.queryByLabelText("Extra headers")).not.toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText("My model provider"), { target: { value: "Company Gateway" }, }); fireEvent.change(screen.getByPlaceholderText("https://api.example.com/v1"), { target: { value: "https://gateway.example/v1" }, }); fireEvent.change(screen.getByPlaceholderText("Enter API key"), { target: { value: "sk-company" }, }); fireEvent.click(screen.getByRole("button", { name: "Advanced options" })); fireEvent.change(screen.getByLabelText("Extra headers"), { target: { value: '{"X-Tenant":"engineering"}' }, }); fireEvent.change(screen.getByLabelText("Extra body"), { target: { value: '{"service_tier":"priority"}' }, }); fireEvent.change(screen.getByLabelText("Extra query"), { target: { value: '{"api-version":"2026-01-01"}' }, }); fireEvent.change(screen.getByLabelText("Network proxy"), { target: { value: "http://127.0.0.1:7890" }, }); fireEvent.pointerDown(screen.getByRole("button", { name: "Thinking style" })); fireEvent.click(await screen.findByRole("menuitem", { name: "enable_thinking" })); fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => { const createCall = requestMutationMock.mock.calls.find( ([action]) => action === "settings.provider.create", ); expect(createCall).toBeTruthy(); expect(createCall?.[1]).toEqual({ name: "Company Gateway", apiKey: "sk-company", apiBase: "https://gateway.example/v1", proxy: "http://127.0.0.1:7890", extraHeaders: '{"X-Tenant":"engineering"}', extraBody: '{"service_tier":"priority"}', extraQuery: '{"api-version":"2026-01-01"}', thinkingStyle: "enable_thinking", }); }); expect( await screen.findByRole("button", { name: /Company Gateway/ }), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Add your own model provider" }), ).toBeInTheDocument(); }); it("selects image models from provider-specific options", async () => { const base = settingsPayload(); const payload: SettingsPayload = { ...base, image_generation: { ...base.image_generation, providers: [ { name: "openrouter", label: "OpenRouter", configured: true, models: ["openai/gpt-5.4-image-2"], default_model: "openai/gpt-5.4-image-2", }, { name: "gemini", label: "Gemini", configured: true, models: ["gemini-2.5-flash-image", "imagen-4.0-generate-001"], default_model: "gemini-2.5-flash-image", }, { name: "custom", label: "Custom", configured: true, models: [], default_model: null, }, ], }, }; renderSettingsView({ initialSection: "image", initialSettings: payload }); expect(screen.queryByDisplayValue("openai/gpt-5.4-image-2")).not.toBeInTheDocument(); fireEvent.pointerDown(screen.getByRole("button", { name: "OpenRouter" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" })); expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument(); await openPopover(screen.getByRole("button", { name: "gemini-2.5-flash-image" })); fireEvent.click(await screen.findByRole("option", { name: "imagen-4.0-generate-001" })); await waitFor(() => expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(), ); await openPopover(screen.getByRole("button", { name: "imagen-4.0-generate-001" })); const modelInput = await screen.findByRole("combobox", { name: "Search or type model ID" }); fireEvent.change(modelInput, { target: { value: "imagen-5-preview" } }); fireEvent.click(await screen.findByRole("option", { name: "Use “imagen-5-preview”" })); expect(await screen.findByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument(); fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" })); expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument(); await openPopover(screen.getByRole("button", { name: "imagen-5-preview" })); const customProviderInput = await screen.findByRole("combobox", { name: "Search or type model ID", }); fireEvent.change(customProviderInput, { target: { value: "private/image-v2" } }); fireEvent.keyDown(customProviderInput, { key: "Enter" }); expect(await screen.findByRole("button", { name: "private/image-v2" })).toBeInTheDocument(); }); it("does not expose the synthetic default configuration as a WebUI preset", async () => { const base = settingsPayload(); const payload: SettingsPayload = { ...base, agent: { ...base.agent, model: "MiniMax-M3", provider: "minimax_anthropic", resolved_provider: "minimax_anthropic", model_preset: "fast", }, model_presets: [ { ...base.model_presets[0], name: "default", label: "Default", active: false, is_default: true, model: "openai-codex/gpt-5.5", provider: "openai_codex", resolved_provider: "openai_codex", }, { ...base.model_presets[0], name: "fast", label: "fast", active: true, is_default: false, model: "MiniMax-M3", provider: "minimax_anthropic", resolved_provider: "minimax_anthropic", }, ], model_call_order: ["fast"], providers: [ { name: "openai_codex", label: "OpenAI Codex", configured: true, auth_type: "oauth", api_key_required: false, api_key_hint: null, api_base: null, default_api_base: null, oauth_account: "acct-test", oauth_expires_at: null, oauth_login_supported: true, }, { name: "minimax_anthropic", label: "MiniMax (Anthropic)", configured: true, auth_type: "api_key", api_key_required: true, api_key_hint: "sk-...", api_base: "https://api.minimax.io/anthropic", default_api_base: "https://api.minimax.io/anthropic", }, ], }; vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); renderSettingsView({ initialSection: "models", initialSettings: payload }); expect((await screen.findAllByText("MiniMax-M3")).length).toBeGreaterThan(0); expect(screen.getAllByText("fast").length).toBeGreaterThan(0); expect(screen.queryByText("Default")).not.toBeInTheDocument(); expect(screen.queryByText("openai-codex/gpt-5.5")).not.toBeInTheDocument(); }); it("does not expose the synthetic default preset in the overview summary", async () => { const base = settingsPayload(); const payload: SettingsPayload = { ...base, agent: { ...base.agent, model_preset: "default", }, model_presets: [ { ...base.model_presets[0], name: "default", label: "Default", is_default: true, }, ], model_call_order: [], model_call_order_editable: false, }; vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); renderSettingsView({ initialSection: "overview", initialSettings: payload }); expect(await screen.findByText("openai/gpt-4o")).toBeInTheDocument(); expect(screen.queryByText("openai · default")).not.toBeInTheDocument(); expect(screen.getByText("openai")).toBeInTheDocument(); }); it("uses the resolved provider row for auto dynamic providers without api keys", async () => { vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); renderSettingsView({ initialSection: "models", initialSettings: autoDynamicProviderPayload({ configured: true, hasApiKey: false, apiBase: "https://proxy.example.test/v1", apiKeyHint: null, }), }); expect((await screen.findAllByText("companyProxy/gpt-4o")).length).toBeGreaterThan(0); expect(screen.getAllByText("Company Proxy").length).toBeGreaterThan(0); expect(screen.queryByText("Provider setup required")).not.toBeInTheDocument(); }); it("does not treat auto dynamic provider api keys as configured without apiBase", async () => { vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); renderSettingsView({ initialSection: "models", initialSettings: autoDynamicProviderPayload({ configured: false, hasApiKey: true, apiBase: null, apiKeyHint: "sk-...", }), }); expect((await screen.findAllByText("companyProxy/gpt-4o")).length).toBeGreaterThan(0); expect(screen.getByText("Provider setup required")).toBeInTheDocument(); expect( screen.queryByText("Configure this provider before saving the preset."), ).not.toBeInTheDocument(); }); it("marks the current model as unconfigured when its provider needs setup", async () => { const payload: SettingsPayload = { ...settingsPayload(), agent: { ...settingsPayload().agent, model: "openai-codex/gpt-5.1-codex", provider: "openai_codex", resolved_provider: "openai_codex", has_api_key: false, }, model_presets: [ { ...settingsPayload().model_presets[0], model: "openai-codex/gpt-5.1-codex", provider: "openai_codex", }, ], providers: [ { name: "openai_codex", label: "OpenAI Codex", configured: false, auth_type: "oauth", api_key_required: false, api_key_hint: null, api_base: null, default_api_base: null, oauth_account: null, oauth_expires_at: null, oauth_login_supported: true, }, ], }; 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: "models" }); expect(await screen.findByText("Provider setup required")).toBeInTheDocument(); await togglePresetEditor(); expect(screen.getAllByText(/Sign in before saving/).length).toBeGreaterThan(0); expect(await screen.findByRole("button", { name: "Sign in" })).toBeInTheDocument(); }); it("keeps unsigned OAuth providers out of the active provider picker", async () => { const payload: SettingsPayload = { ...settingsPayload(), agent: { ...settingsPayload().agent, model: "deepseek-chat", provider: "deepseek", resolved_provider: "deepseek", }, model_presets: [ { ...settingsPayload().model_presets[0], model: "deepseek-chat", provider: "deepseek", }, ], providers: [ { name: "deepseek", label: "DeepSeek", configured: true, auth_type: "api_key", api_key_required: true, api_key_hint: "sk-...", api_base: "https://api.deepseek.com", default_api_base: "https://api.deepseek.com", }, { name: "openai_codex", label: "OpenAI Codex", configured: false, auth_type: "oauth", api_key_required: false, api_key_hint: null, api_base: null, default_api_base: null, oauth_account: null, oauth_expires_at: null, oauth_login_supported: true, }, { name: "github_copilot", label: "GitHub Copilot", configured: false, auth_type: "oauth", api_key_required: false, api_key_hint: null, api_base: null, default_api_base: "https://api.githubcopilot.com", oauth_account: null, oauth_expires_at: null, oauth_login_supported: true, }, ], }; 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: "models" }); await togglePresetEditor(); const deepseekButtons = await screen.findAllByRole("button", { name: /DeepSeek/ }); const providerPicker = deepseekButtons.find( (button) => button.getAttribute("aria-haspopup") === "menu", ); if (!providerPicker) throw new Error("provider picker was not found"); fireEvent.pointerDown(providerPicker); expect(await screen.findByRole("menuitem", { name: /DeepSeek/ })).toBeInTheDocument(); expect(screen.queryByRole("menuitem", { name: /OpenAI Codex/ })).not.toBeInTheDocument(); expect(screen.queryByRole("menuitem", { name: /GitHub Copilot/ })).not.toBeInTheDocument(); }); it("does not fetch model lists for unsigned OAuth providers", async () => { const payload: SettingsPayload = { ...settingsPayload(), agent: { ...settingsPayload().agent, model: "", provider: "openai_codex", resolved_provider: "openai_codex", }, model_presets: [ { ...settingsPayload().model_presets[0], model: "", provider: "openai_codex", }, ], providers: [ { name: "openai_codex", label: "OpenAI Codex", configured: false, auth_type: "oauth", api_key_required: false, api_key_hint: null, api_base: null, default_api_base: null, oauth_account: null, oauth_expires_at: null, oauth_login_supported: true, }, { name: "github_copilot", label: "GitHub Copilot", configured: false, auth_type: "oauth", api_key_required: false, api_key_hint: null, api_base: null, default_api_base: "https://api.githubcopilot.com", oauth_account: null, oauth_expires_at: null, oauth_login_supported: true, }, ], }; 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 }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); renderSettingsView({ initialSection: "models" }); await togglePresetEditor(); await openPopover(await screen.findByRole("button", { name: /Select model/i })); expect( await screen.findByText("Configure this provider before loading models."), ).toBeInTheDocument(); expect( fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/settings/provider-models"), ), ).toBe(false); }); it("prefills manual model ids for configured OAuth providers", async () => { const payload: SettingsPayload = { ...settingsPayload(), agent: { ...settingsPayload().agent, model: "open-codex/gpt-5.5", provider: "openai_codex", resolved_provider: "openai_codex", }, model_presets: [ { ...settingsPayload().model_presets[0], model: "open-codex/gpt-5.5", provider: "openai_codex", }, ], providers: [ { name: "openai_codex", label: "OpenAI Codex", configured: true, auth_type: "oauth", api_key_required: false, api_key_hint: null, api_base: null, default_api_base: null, oauth_account: "acct-test", oauth_expires_at: null, oauth_login_supported: true, }, ], }; 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 }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); renderSettingsView({ initialSection: "models" }); await togglePresetEditor(); const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i }); await openPopover(modelButtons[modelButtons.length - 1]); const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement; expect(input.value).toBe("open-codex/gpt-5.5"); fireEvent.change(input, { target: { value: "openai-codex/gpt-5.5" } }); expect(await screen.findByText("“openai-codex/gpt-5.5”")).toBeInTheDocument(); expect( fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/settings/provider-models"), ), ).toBe(false); }); it("loads curated models for configured OAuth providers", async () => { const base = settingsPayload(); const payload: SettingsPayload = { ...base, agent: { ...base.agent, model: "openai-codex/gpt-5.5", provider: "openai_codex", resolved_provider: "openai_codex", }, model_presets: [ { ...base.model_presets[0], model: "openai-codex/gpt-5.5", provider: "openai_codex", }, ], providers: [ { name: "openai_codex", label: "OpenAI Codex", configured: true, auth_type: "oauth", api_key_required: false, api_key_hint: null, api_base: null, default_api_base: "https://chatgpt.com/backend-api", model_catalog: "builtin", oauth_account: "acct-test", oauth_expires_at: null, oauth_login_supported: true, }, ], }; const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings/provider-models?provider=openai_codex") { return jsonResponse({ provider: "openai_codex", label: "OpenAI Codex", status: "available", catalog_kind: "builtin", models: [ { id: "openai-codex/gpt-5.6-sol", label: "GPT-5.6-Sol", description: "Latest frontier agentic coding model.", owned_by: "OpenAI Codex", context_window: 372000, }, ], model_count: 1, fetched_at: 1, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); renderSettingsView({ initialSection: "models", initialSettings: payload }); await togglePresetEditor(); const modelButtons = await screen.findAllByRole("button", { name: /openai-codex\/gpt-5\.5/i, }); await openPopover(modelButtons[modelButtons.length - 1]); expect(await screen.findByText("GPT-5.6-Sol")).toBeInTheDocument(); expect(screen.getByText(/Latest frontier agentic coding model\./)).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith( "/api/settings/provider-models?provider=openai_codex", expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), ); }); it("creates presets in the inline editor and can cancel without opening a dialog", 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" }); fireEvent.click(await screen.findByRole("button", { name: "New model preset" })); expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument(); expect(screen.getByPlaceholderText("Fast writing")).toHaveValue(""); expect(screen.queryByText("Temperature")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /Advanced options/ })); expect(screen.getByText("Temperature")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(screen.queryByDisplayValue("Primary")).not.toBeInTheDocument(); expect(screen.queryByText("Edit preset")).not.toBeInTheDocument(); expect(document.body.style.pointerEvents).not.toBe("none"); fireEvent.click(screen.getByRole("button", { name: "New model preset" })); expect(await screen.findByPlaceholderText("Fast writing")).toHaveValue(""); }); it("loads provider models and lets users choose one without typing the id manually", async () => { const payload: SettingsPayload = { ...settingsPayload(), agent: { ...settingsPayload().agent, model: "deepseek-chat", provider: "deepseek", resolved_provider: "deepseek", }, model_presets: [ { ...settingsPayload().model_presets[0], model: "deepseek-chat", provider: "deepseek", }, ], providers: [ { name: "deepseek", label: "DeepSeek", configured: true, auth_type: "api_key", api_key_required: true, api_key_hint: "sk-...", api_base: "https://api.deepseek.com", default_api_base: "https://api.deepseek.com", }, ], }; const updatedPayload: SettingsPayload = { ...payload, agent: { ...payload.agent, model: "deepseek-reasoner", temperature: 0.4, }, model_presets: [ { ...payload.model_presets[0], model: "deepseek-reasoner", temperature: 0.4, reasoning_effort: "provider-native-mode", }, ], }; 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/provider-models?provider=deepseek") { return jsonResponse({ provider: "deepseek", label: "DeepSeek", status: "available", catalog_kind: "official", models: [ { id: "deepseek-chat", owned_by: "deepseek", context_window: 65536 }, { id: "deepseek-reasoner", owned_by: "deepseek", context_window: 65536 }, ], model_count: 2, fetched_at: 1, }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce(updatedPayload); renderSettingsView({ initialSection: "models" }); await togglePresetEditor(); const modelButtons = await screen.findAllByRole("button", { name: /deepseek-chat/i }); await openPopover(modelButtons[modelButtons.length - 1]); await screen.findByText("deepseek-reasoner"); fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]); fireEvent.click(screen.getByRole("button", { name: /Advanced options/ })); fireEvent.change(screen.getByLabelText("Temperature"), { target: { value: "0.4" }, }); fireEvent.change(screen.getByLabelText("Reasoning effort"), { target: { value: "provider-native-mode" }, }); fireEvent.click(screen.getByRole("button", { name: "Save preset" })); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( "/api/settings/provider-models?provider=deepseek", expect.objectContaining({ headers: { Authorization: "Bearer tok" }, }), ), ); await waitFor(() => { const saveCall = requestMutationMock.mock.calls.find(([action]) => action === "settings.model_configuration.update", ); expect(saveCall).toBeDefined(); expect(saveCall?.[1]).toEqual({ name: "primary", model: "deepseek-reasoner", reasoning_effort: "provider-native-mode", temperature: 0.4, }); }); }); 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 }); } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce({ ...payload, advanced: { ...payload.advanced, webui_allow_local_service_access: false }, requires_restart: true, restart_required_sections: ["runtime"], }); 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(requestMutationMock).toHaveBeenCalledWith( "settings.network_safety.update", { webui_allow_local_service_access: false, webui_default_access_mode: "default", }, 20_000, ), ); }); it("saves optional-key web search providers without an API key", async () => { const payload = { ...settingsPayload(), web_search: { ...settingsPayload().web_search, provider: "duckduckgo", providers: [ { name: "duckduckgo", label: "DuckDuckGo", credential: "none" as const }, { name: "keenable", label: "Keenable", credential: "optional_api_key" as const }, ], }, }; const updatedPayload = { ...payload, web_search: { ...payload.web_search, provider: "keenable", }, }; 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 }); return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce(updatedPayload); renderSettingsView({ initialSection: "browser" }); fireEvent.pointerDown(await screen.findByRole("button", { name: /DuckDuckGo/ })); fireEvent.click(await screen.findByRole("menuitem", { name: "Keenable" })); const saveButton = screen .getAllByRole("button", { name: "Save" }) .find((button) => !(button as HTMLButtonElement).disabled); if (!saveButton) throw new Error("enabled Save button was not found"); fireEvent.click(saveButton); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( "settings.web_search.update", { provider: "keenable", max_results: 5, timeout: 30, use_jina_reader: true, }, 20_000, ), ); }); 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(); }); it("refreshes settings with a fresh token after native engine restart", async () => { const payload = { ...settingsPayload(), surface: "native" as const, runtime_surface: "native" as const, runtime_capabilities: { can_restart_engine: true, can_pick_folder: true, can_open_logs: true, can_export_diagnostics: true, }, }; const restartedPayload = { ...payload, advanced: { ...payload.advanced, webui_allow_local_service_access: false }, requires_restart: true, restart_required_sections: ["runtime"], }; const refreshedPayload = { ...restartedPayload, requires_restart: false, restart_required_sections: [], }; const restartEngine = vi.fn(async () => "fresh-token"); const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const auth = (init?.headers as Record | undefined)?.Authorization; if (url === "/api/settings" && auth === "Bearer fresh-token") { return jsonResponse(refreshedPayload); } 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; }); vi.stubGlobal("fetch", fetchMock); requestMutationMock.mockResolvedValueOnce(restartedPayload); renderSettingsView({ initialSection: "advanced", onNativeEngineRestart: restartEngine, }); expect(await screen.findByText("App safety")).toBeInTheDocument(); fireEvent.click(screen.getByRole("switch", { name: "Local services" })); fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => expect(restartEngine).toHaveBeenCalledTimes(1)); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( "/api/settings", expect.objectContaining({ headers: { Authorization: "Bearer fresh-token" }, }), ), ); }); });