mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
4985 lines
179 KiB
TypeScript
4985 lines
179 KiB
TypeScript
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<typeof channelSetupField>[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<string>;
|
|
} = {},
|
|
) {
|
|
render(
|
|
<ClientProvider client={{ requestMutation: requestMutationMock } as never} token="tok">
|
|
<SettingsView
|
|
theme="light"
|
|
initialSection={options.initialSection ?? "apps"}
|
|
initialSettings={options.initialSettings}
|
|
showSidebar={options.showSidebar}
|
|
onToggleTheme={() => {}}
|
|
onBackToChat={options.onBackToChat ?? (() => {})}
|
|
onModelNameChange={() => {}}
|
|
onSettingsChange={options.onSettingsChange}
|
|
onNativeEngineRestart={options.onNativeEngineRestart}
|
|
/>
|
|
</ClientProvider>,
|
|
);
|
|
}
|
|
|
|
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<Response>(() => {})),
|
|
);
|
|
});
|
|
|
|
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<Response>((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<string, string>;
|
|
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<Response>(() => {});
|
|
}
|
|
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<Response>(() => {});
|
|
});
|
|
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<Response>((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<Response>(() => {})));
|
|
|
|
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<string, unknown>,
|
|
) => {
|
|
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<string, string>,
|
|
) => {
|
|
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<Response>(() => {})));
|
|
|
|
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<Response>(() => {})));
|
|
|
|
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<Response>(() => {})));
|
|
|
|
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<Response>(() => {})));
|
|
|
|
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<string, string> | 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" },
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
});
|