2026-07-30 10:25:22 +08:00
import { act , fireEvent , render , screen , waitFor , within } from "@testing-library/react" ;
2026-08-04 15:32:29 +08:00
import userEvent from "@testing-library/user-event" ;
2026-07-13 13:11:46 +08:00
import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
2026-05-25 20:07:02 +08:00
import { SettingsView } from "@/components/settings/SettingsView" ;
import { ClientProvider } from "@/providers/ClientProvider" ;
2026-07-19 23:30:49 +08:00
import type {
ChannelSetupContract ,
ChannelSetupContractField ,
SettingsPayload ,
} from "@/lib/types" ;
2026-05-25 20:07:02 +08:00
function jsonResponse ( body : unknown ) : Response {
return {
ok : true ,
status : 200 ,
json : async () => body ,
} as Response ;
}
2026-05-29 03:42:53 +08:00
function settingsPayload () : SettingsPayload {
2026-05-25 20:07:02 +08:00
return {
agent : {
model : "openai/gpt-4o" ,
provider : "auto" ,
resolved_provider : "openai" ,
has_api_key : true ,
2026-07-24 00:55:06 +08:00
model_preset : "primary" ,
2026-05-25 20:07:02 +08:00
max_tokens : 8192 ,
2026-06-22 16:27:30 +08:00
context_window_tokens : 200000 ,
2026-05-25 20:07:02 +08:00
temperature : 0.1 ,
reasoning_effort : null ,
timezone : "UTC" ,
tool_hint_max_length : 40 ,
},
model_presets : [{
2026-07-24 00:55:06 +08:00
name : "primary" ,
label : "Primary" ,
2026-05-25 20:07:02 +08:00
active : true ,
2026-07-24 00:55:06 +08:00
is_default : false ,
2026-05-25 20:07:02 +08:00
model : "openai/gpt-4o" ,
provider : "auto" ,
2026-07-24 00:55:06 +08:00
resolved_provider : "openai" ,
2026-05-25 20:07:02 +08:00
max_tokens : 8192 ,
2026-06-22 16:27:30 +08:00
context_window_tokens : 200000 ,
2026-05-25 20:07:02 +08:00
temperature : 0.1 ,
reasoning_effort : null ,
}],
2026-07-24 00:55:06 +08:00
model_call_order : [ "primary" ],
model_call_order_editable : true ,
2026-05-25 20:07:02 +08:00
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 },
},
2026-07-13 13:11:46 +08:00
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" ,
},
2026-05-25 20:07:02 +08:00
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 ,
2026-05-29 03:42:53 +08:00
webui_allow_local_service_access : true ,
webui_default_access_mode : "default" ,
private_service_protection_enabled : true ,
2026-05-25 20:07:02 +08:00
ssrf_whitelist_count : 0 ,
mcp_server_count : 0 ,
exec_enabled : true ,
exec_sandbox : null ,
2026-06-10 15:50:24 +08:00
exec_path_prepend_set : false ,
2026-05-25 20:07:02 +08:00
exec_path_append_set : false ,
},
requires_restart : false ,
2026-07-13 13:11:46 +08:00
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" ,
},
2026-05-25 20:07:02 +08:00
};
}
2026-07-24 00:55:06 +08:00
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 ,
},
],
},
};
}
2026-07-19 23:30:49 +08:00
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" ,
}),
],
};
}
}
2026-06-12 10:16:54 +08:00
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" ,
2026-07-24 00:55:06 +08:00
resolved_provider : "companyProxy" ,
2026-06-12 10:16:54 +08:00
},
],
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 ,
},
],
};
}
2026-05-25 20:07:02 +08:00
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 ,
};
2026-05-29 03:42:53 +08:00
function renderSettingsView (
options : {
2026-07-13 13:11:46 +08:00
initialSection ?:
| "overview"
| "appearance"
| "apps"
| "channels"
| "automations"
| "advanced"
| "models"
2026-07-17 13:02:49 +08:00
| "image"
2026-07-13 13:11:46 +08:00
| "browser"
| "runtime" ;
2026-06-08 14:29:31 +08:00
initialSettings? : SettingsPayload ;
2026-06-15 18:08:44 +08:00
showSidebar? : boolean ;
2026-05-29 03:42:53 +08:00
onSettingsChange ?: ( payload : SettingsPayload ) => void ;
2026-06-06 19:49:33 +08:00
onNativeEngineRestart ?: () => Promise < string >;
2026-05-29 03:42:53 +08:00
} = {},
) {
2026-05-25 20:07:02 +08:00
render (
< ClientProvider client = {{} as never } token = "tok" >
< SettingsView
theme = "light"
2026-05-29 03:42:53 +08:00
initialSection = { options . initialSection ?? "apps" }
2026-06-08 14:29:31 +08:00
initialSettings = { options . initialSettings }
2026-06-15 18:08:44 +08:00
showSidebar = { options . showSidebar }
2026-05-25 20:07:02 +08:00
onToggleTheme = {() => {}}
onBackToChat = {() => {}}
onModelNameChange = {() => {}}
2026-05-29 03:42:53 +08:00
onSettingsChange = { options . onSettingsChange }
2026-06-06 19:49:33 +08:00
onNativeEngineRestart = { options . onNativeEngineRestart }
2026-05-25 20:07:02 +08:00
/>
</ ClientProvider >,
);
}
2026-07-24 00:55:06 +08:00
async function togglePresetEditor ( name = "primary" ) {
const row = await screen . findByTestId ( `model-call-order-row- ${ name } ` );
fireEvent . click ( within ( row ). getAllByRole ( "button" )[ 0 ]);
}
2026-08-04 15:32:29 +08:00
async function openPopover ( trigger : HTMLElement ) {
await userEvent . setup (). click ( trigger );
}
2026-07-24 00:55:06 +08:00
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 }));
}
2026-05-25 20:07:02 +08:00
describe ( "SettingsView Apps catalog" , () => {
2026-07-13 13:11:46 +08:00
beforeEach (() => {
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 (),
})),
);
});
2026-05-25 20:07:02 +08:00
afterEach (() => {
2026-07-08 11:25:45 +08:00
localStorage . removeItem ( "nanobot-webui.settings-preferences" );
2026-06-08 14:29:31 +08:00
vi . useRealTimers ();
2026-05-25 20:07:02 +08:00
vi . unstubAllGlobals ();
});
2026-07-08 11:25:45 +08:00
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" );
});
});
2026-06-15 18:08:44 +08:00
it ( "does not show the Settings kicker on the standalone Automations surface" , 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 : [] });
return jsonResponse ({});
}));
renderSettingsView ({
initialSection : "automations" ,
initialSettings : settingsPayload (),
showSidebar : false ,
});
expect ( screen . getByRole ( "heading" , { name : "Automations" })). toBeInTheDocument ();
expect ( await screen . findByText ( "No automations yet." )). toBeInTheDocument ();
expect ( screen . queryByText ( "Settings" )). not . toBeInTheDocument ();
});
2026-07-30 10:25:22 +08:00
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 ;
});
});
2026-07-13 13:11:46 +08:00
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 });
}
if ( url === "/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120" ) {
return jsonResponse ({ ... stopped , installed : true , running : true , managed : true });
}
return jsonResponse ({});
});
vi . stubGlobal ( "fetch" , fetchMock );
renderSettingsView ({ initialSection : "runtime" , initialSettings : base , showSidebar : true });
2026-07-30 10:25:22 +08:00
const startButton = await screen . findByRole ( "button" , { name : "Start API server" });
await waitFor (() => expect ( startButton ). toBeEnabled ());
fireEvent . click ( startButton );
2026-07-13 13:11:46 +08:00
await waitFor (() => {
expect ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120" ,
expect . any ( Object ),
);
});
});
2026-05-25 20:07:02 +08:00
it ( "shows a visible uninstall button for installed CLI apps and calls uninstall" , async () => {
const fetchMock = vi . fn ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
if ( url === "/api/settings" ) {
return jsonResponse ( settingsPayload ());
}
if ( url === "/api/settings/cli-apps" ) {
return jsonResponse ({
apps : [ installedAnyGen ],
installed_count : 1 ,
catalog_updated_at : "2026-04-18" ,
});
}
if ( url === "/api/settings/mcp-presets" ) {
return jsonResponse ({ presets : [], installed_count : 0 });
}
if ( url === "/api/settings/cli-apps/uninstall?name=anygen" ) {
return jsonResponse ({
apps : [{ ... installedAnyGen , installed : false , status : "available" }],
installed_count : 0 ,
catalog_updated_at : "2026-04-18" ,
last_action : {
ok : true ,
message : "Uninstalled CLI for AnyGen." ,
still_available : false ,
},
});
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
renderSettingsView ();
2026-07-24 00:55:06 +08:00
expect ( screen . queryByRole ( "heading" , { name : "Apps" })). not . toBeInTheDocument ();
2026-05-25 20:07:02 +08:00
expect ( await screen . findByText ( "AnyGen" )). toBeInTheDocument ();
2026-07-13 13:11:46 +08:00
const uninstall = screen . getByRole ( "button" , { name : "Uninstall app" });
2026-05-25 20:07:02 +08:00
fireEvent . click ( uninstall );
await waitFor (() =>
expect ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/cli-apps/uninstall?name=anygen" ,
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
),
);
expect ( await screen . findByText ( "Uninstalled CLI for AnyGen." )). toBeInTheDocument ();
fireEvent . click ( screen . getByRole ( "button" , { name : "Dismiss" }));
expect ( screen . queryByText ( "Uninstalled CLI for AnyGen." )). not . toBeInTheDocument ();
});
2026-05-29 03:42:53 +08:00
2026-07-13 13:11:46 +08:00
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" ) {
2026-07-22 22:35:39 +08:00
return jsonResponse ({
apps : [{ ... installedAnyGen , installed : false , status : "available" }],
installed_count : 0 ,
});
2026-07-13 13:11:46 +08:00
}
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" });
2026-08-05 13:24:45 +08:00
expect ( await screen . findByText ( "AnyGen" )). toBeInTheDocument ();
expect (
screen . queryByText ( "Add tools to nanobot, then @ them in chat." ),
). not . toBeInTheDocument ();
2026-07-22 22:35:39 +08:00
expect ( screen . getByRole ( "button" , { name : "Ready" })). toHaveAttribute ( "aria-pressed" , "false" );
expect ( screen . getByRole ( "button" , { name : "Apps" })). toHaveAttribute ( "aria-pressed" , "true" );
2026-07-13 13:11:46 +08:00
expect ( screen . getByRole ( "button" , { name : "Integrations" })). toBeInTheDocument ();
expect ( screen . queryByRole ( "button" , { name : "Plugins" })). not . toBeInTheDocument ();
expect ( screen . queryByText ( "Api" )). not . toBeInTheDocument ();
2026-08-05 13:24:45 +08:00
expect ( screen . queryByText ( "0 ready" )). not . toBeInTheDocument ();
2026-07-13 13:11:46 +08:00
});
2026-07-03 18:17:52 +08:00
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" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.ts" ,
2026-07-03 18:17:52 +08:00
type : "channel" ,
enabled : false ,
installed : false ,
ready : false ,
status : "missing_dependency" ,
install_supported : true ,
requires_restart : true ,
}],
enabled_count : 0 ,
});
}
if ( url === "/api/settings/nanobot-features/enable?name=matrix" ) {
return jsonResponse ({
features : [{
name : "matrix" ,
display_name : "Matrix" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.ts" ,
2026-07-03 18:17:52 +08:00
type : "channel" ,
enabled : true ,
2026-07-19 23:30:49 +08:00
running : true ,
runtime_status : "running" ,
2026-07-03 18:17:52 +08:00
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 ( url === "/api/settings/nanobot-features/disable?name=matrix" ) {
return jsonResponse ({
features : [{
name : "matrix" ,
display_name : "Matrix" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.ts" ,
2026-07-03 18:17:52 +08:00
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 { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
2026-07-13 13:11:46 +08:00
renderSettingsView ({ initialSection : "channels" });
2026-07-03 18:17:52 +08:00
2026-07-13 13:11:46 +08:00
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 );
2026-07-03 18:17:52 +08:00
expect ( screen . queryByText ( /Enabling Nanobot features may install Python packages/ )). not . toBeInTheDocument ();
2026-07-13 13:11:46 +08:00
fireEvent . click ( screen . getByRole ( "switch" , { name : "Matrix channel" }));
2026-07-03 18:17:52 +08:00
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 ( fetchMock ). not . toHaveBeenCalledWith (
"/api/settings/nanobot-features/enable?name=matrix" ,
expect . anything (),
);
fireEvent . click ( screen . getByRole ( "button" , { name : "Install and enable" }));
await waitFor (() =>
expect ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/nanobot-features/enable?name=matrix" ,
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
),
);
2026-07-13 13:11:46 +08:00
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 );
2026-07-03 18:17:52 +08:00
2026-07-13 13:11:46 +08:00
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" }));
2026-07-03 18:17:52 +08:00
await waitFor (() =>
expect ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/nanobot-features/disable?name=matrix" ,
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
),
);
2026-07-13 13:11:46 +08:00
await waitFor (() =>
expect ( screen . getByRole ( "switch" , { name : "Matrix channel" })). toHaveAttribute ( "aria-checked" , "false" ),
);
expect ( screen . queryByText ( "Disabled channel 'matrix'" )). not . toBeInTheDocument ();
2026-07-03 18:17:52 +08:00
});
2026-07-19 23:30:49 +08:00
it ( "shows an enabled channel with missing support as failed" , async () => {
2026-07-03 18:17:52 +08:00
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 ,
2026-07-19 23:30:49 +08:00
running : false ,
runtime_status : "failed" ,
runtime_error : "Channel dependencies could not be installed. Check gateway logs." ,
2026-07-03 18:17:52 +08:00
installed : false ,
ready : false ,
status : "missing_dependency" ,
install_supported : true ,
requires_restart : true ,
}],
enabled_count : 1 ,
});
}
if ( url === "/api/settings/nanobot-features/enable?name=matrix" ) {
return jsonResponse ({
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 },
});
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
2026-07-13 13:11:46 +08:00
renderSettingsView ({ initialSection : "channels" });
2026-07-03 18:17:52 +08:00
2026-07-13 13:11:46 +08:00
expect ( await screen . findByRole ( "button" , { name : "View Matrix settings" })). toBeInTheDocument ();
2026-08-05 13:24:45 +08:00
expect ( screen . queryByText ( "0 running · 1 channels" )). not . toBeInTheDocument ();
2026-07-19 23:30:49 +08:00
expect ( screen . getAllByText ( "Failed" ). length ). toBeGreaterThan ( 0 );
2026-07-13 13:11:46 +08:00
expect ( screen . queryByText ( "Enabled, support needs install" )). not . toBeInTheDocument ();
2026-07-03 18:17:52 +08:00
2026-07-19 23:30:49 +08:00
expect ( screen . getByRole ( "switch" , { name : "Matrix channel" })). toHaveAttribute ( "aria-checked" , "false" );
2026-07-03 18:17:52 +08:00
fireEvent . click ( screen . getByRole ( "button" , { name : "Install support" }));
fireEvent . click ( screen . getByRole ( "button" , { name : "Install and enable" }));
await waitFor (() =>
expect ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/nanobot-features/enable?name=matrix" ,
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
),
);
});
2026-07-19 23:30:49 +08:00
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 ();
2026-08-05 13:24:45 +08:00
expect ( screen . queryByText ( "0 running · 1 channels" )). not . toBeInTheDocument ();
2026-07-19 23:30:49 +08:00
expect ( screen . getAllByText ( "Failed" ). length ). toBeGreaterThan ( 0 );
expect ( screen . getByText ( runtimeError )). toBeInTheDocument ();
expect ( screen . getByRole ( "switch" , { name : "Matrix channel" })). toHaveAttribute (
"aria-checked" ,
"false" ,
);
});
2026-07-13 13:11:46 +08:00
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" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.tsx" ,
2026-07-13 13:11:46 +08:00
type : "channel" ,
enabled : false ,
configured : false ,
installed : true ,
ready : false ,
status : "not_enabled" ,
install_supported : true ,
requires_restart : true ,
}],
enabled_count : 0 ,
});
}
if ( url === "/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace" ) {
return jsonResponse ({
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." ,
});
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
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 ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace" ,
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
),
);
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" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.tsx" ,
2026-07-13 13:11:46 +08:00
type : "channel" ,
enabled : false ,
configured : false ,
installed : true ,
ready : false ,
status : "not_enabled" ,
install_supported : true ,
requires_restart : true ,
}],
enabled_count : 0 ,
});
}
if ( url === "/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace" ) {
return jsonResponse ({
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." ,
});
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
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 ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace" ,
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
),
);
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" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.tsx" ,
2026-07-13 13:11:46 +08:00
type : "channel" ,
enabled : false ,
configured : true ,
installed : true ,
ready : false ,
status : "not_enabled" ,
install_supported : true ,
requires_restart : true ,
}],
enabled_count : 0 ,
});
}
if ( url === "/api/settings/nanobot-features/enable?name=feishu&instance_id=default" ) {
return jsonResponse ({
features : [{
name : "feishu" ,
display_name : "Feishu" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.tsx" ,
2026-07-13 13:11:46 +08:00
type : "channel" ,
enabled : true ,
2026-07-19 23:30:49 +08:00
running : true ,
runtime_status : "running" ,
2026-07-13 13:11:46 +08:00
configured : true ,
instances : [{
id : "default" ,
name : "nanobot" ,
enabled : true ,
2026-07-19 23:30:49 +08:00
running : true ,
runtime_status : "running" ,
2026-07-13 13:11:46 +08:00
configured : true ,
2026-07-19 23:30:49 +08:00
config_values : { "channels.feishu.appId" : "cli_test" },
configured_fields : [
"channels.feishu.appId" ,
"channels.feishu.appSecret" ,
],
2026-07-13 13:11:46 +08:00
}],
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 ( url === "/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace" ) {
throw new Error ( "Feishu connect should not start when credentials are already configured" );
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
renderSettingsView ({ initialSection : "channels" });
fireEvent . click ( await screen . findByRole ( "button" , { name : "nanobot" }));
fireEvent . click ( await screen . findByRole ( "switch" , { name : "nanobot assistant" }));
await waitFor (() =>
expect ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/nanobot-features/enable?name=feishu&instance_id=default" ,
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
),
);
expect ( fetchMock . mock . calls . some (([ input ]) =>
String ( input ) === "/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace" ,
)). 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" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.tsx" ,
2026-07-13 13:11:46 +08:00
type : "channel" ,
enabled : true ,
configured : true ,
installed : true ,
ready : true ,
status : "enabled" ,
install_supported : true ,
requires_restart : true ,
2026-07-19 23:30:49 +08:00
setup : channelSetupContract ( "feishu" ),
2026-07-13 13:11:46 +08:00
instances : [
{
id : "default" ,
name : "nanobot" ,
display_name : "Support Bot" ,
avatar_url : "https://example.com/support.png" ,
enabled : true ,
configured : true ,
2026-07-19 23:30:49 +08:00
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" ,
],
2026-07-13 13:11:46 +08:00
},
{
id : "product" ,
name : "Product bot" ,
display_name : "Product Helper" ,
avatar_url : "https://example.com/product.png" ,
enabled : false ,
configured : true ,
2026-07-19 23:30:49 +08:00
config_values : { "channels.feishu.appId" : "cli_product" },
configured_fields : [
"channels.feishu.appId" ,
"channels.feishu.appSecret" ,
],
2026-07-13 13:11:46 +08:00
},
],
}],
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 );
2026-07-19 23:30:49 +08:00
expect ( screen . getByText ( "Advanced" )). toBeInTheDocument ();
expect ( screen . getByText ( "Topic isolation" )). toBeInTheDocument ();
2026-07-13 13:11:46 +08:00
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" ,
);
});
2026-07-19 23:30:49 +08:00
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 ();
});
2026-07-13 13:11:46 +08:00
it ( "shows a single Feishu assistant without a duplicate assistant list" , async () => {
const reconnectUrls : string [] = [];
const feishuPayload = {
features : [{
name : "feishu" ,
display_name : "Feishu" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.tsx" ,
2026-07-13 13:11:46 +08:00
type : "channel" ,
enabled : true ,
configured : true ,
installed : true ,
ready : true ,
status : "enabled" ,
2026-07-19 23:30:49 +08:00
running : true ,
runtime_status : "running" ,
2026-07-13 13:11:46 +08:00
install_supported : true ,
requires_restart : true ,
instances : [{
id : "default" ,
name : "nanobot" ,
display_name : "Support Bot" ,
avatar_url : "https://example.com/support.png" ,
enabled : true ,
2026-07-19 23:30:49 +08:00
running : true ,
runtime_status : "running" ,
2026-07-13 13:11:46 +08:00
configured : true ,
2026-07-19 23:30:49 +08:00
config_values : { "channels.feishu.appId" : "cli_support" },
configured_fields : [
"channels.feishu.appId" ,
"channels.feishu.appSecret" ,
],
2026-07-13 13:11:46 +08:00
}],
}],
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 );
if ( url === "/api/settings/nanobot-features/enable?name=feishu&instance_id=default" ) {
reconnectUrls . push ( url );
return jsonResponse ( feishuPayload );
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
}),
);
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 ( reconnectUrls ). toHaveLength ( 1 ));
expect ( document . querySelector ( 'img[src="https://example.com/support.png"]' )). toBeTruthy ();
});
2026-07-19 23:30:49 +08:00
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" );
2026-08-05 13:24:45 +08:00
expect ( screen . queryByText ( "0 running · 1 channels" )). not . toBeInTheDocument ();
2026-07-19 23:30:49 +08:00
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 ();
});
2026-07-13 13:11:46 +08:00
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" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.ts" ,
2026-07-13 13:11:46 +08:00
type : "channel" ,
enabled : true ,
installed : true ,
ready : true ,
status : "enabled" ,
install_supported : true ,
requires_restart : true ,
2026-07-19 23:30:49 +08:00
setup : channelSetupContract ( "discord" ),
2026-07-13 13:11:46 +08:00
}],
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" });
2026-07-23 18:18:34 +08:00
expect ( screen . getByPlaceholderText ( "Search channels" )). toHaveClass (
"focus-visible:ring-0" ,
"focus-visible:ring-offset-0" ,
);
2026-07-13 13:11:46 +08:00
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" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.ts" ,
2026-07-13 13:11:46 +08:00
type : "channel" ,
enabled : false ,
configured : false ,
installed : true ,
ready : false ,
status : "not_enabled" ,
install_supported : true ,
requires_restart : true ,
2026-07-19 23:30:49 +08:00
setup : channelSetupContract ( "discord" ),
2026-07-13 13:11:46 +08:00
}],
enabled_count : 0 ,
});
}
if ( url === "/api/settings/channels/configure?name=discord&enable=true" ) {
return jsonResponse ({
name : "discord" ,
saved : true ,
saved_keys : [
"channels.discord.token" ,
"channels.discord.allowChannels" ,
"channels.discord.groupPolicy" ,
],
nanobot_features : {
features : [{
name : "discord" ,
display_name : "Discord" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.ts" ,
2026-07-13 13:11:46 +08:00
type : "channel" ,
enabled : true ,
2026-07-19 23:30:49 +08:00
running : true ,
runtime_status : "running" ,
2026-07-13 13:11:46 +08:00
configured : true ,
installed : true ,
ready : true ,
status : "enabled" ,
install_supported : true ,
requires_restart : true ,
2026-07-19 23:30:49 +08:00
setup : channelSetupContract ( "discord" ),
2026-07-13 13:11:46 +08:00
}],
enabled_count : 1 ,
requires_restart : false ,
},
});
}
if ( url === "/api/settings/channels/validate?name=discord" ) {
return jsonResponse ({
name : "discord" ,
status : "configured" ,
checks : [{ id : "bot_token" , label : "Bot token" , status : "pass" }],
identity : { name : "nanobot-test" , account : "123" },
missing_fields : [],
can_enable : true ,
requires_restart : false ,
message : "Configuration is present." ,
});
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
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 (
fetchMock . mock . calls . some (
([ input ]) => String ( input ) === "/api/settings/channels/configure?name=discord&enable=true" ,
),
). toBe ( true ),
);
const configureCall = fetchMock . mock . calls . find (
([ input ]) => String ( input ) === "/api/settings/channels/configure?name=discord&enable=true" ,
);
expect (( configureCall ? .[ 1 ] as RequestInit | undefined ) ? . method ). toBeUndefined ();
const headers = ( configureCall ? .[ 1 ] as RequestInit | undefined ) ? . headers as Record < string , string >;
expect ( JSON . parse ( headers [ "X-Nanobot-Channel-Values" ])). toEqual ({
"channels.discord.token" : "discord-token" ,
"channels.discord.allowChannels" : "123, 456" ,
"channels.discord.groupPolicy" : "open" ,
});
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" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.ts" ,
2026-07-13 13:11:46 +08:00
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" ,
],
2026-07-19 23:30:49 +08:00
setup : channelSetupContract ( "discord" ),
2026-07-13 13:11:46 +08:00
}],
enabled_count : 0 ,
});
}
if ( url === "/api/settings/nanobot-features/enable?name=discord" ) {
return jsonResponse ({
features : [{
name : "discord" ,
display_name : "Discord" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.ts" ,
2026-07-13 13:11:46 +08:00
type : "channel" ,
enabled : true ,
configured : true ,
installed : true ,
ready : true ,
status : "enabled" ,
install_supported : true ,
requires_restart : true ,
2026-07-19 23:30:49 +08:00
setup : channelSetupContract ( "discord" ),
2026-07-13 13:11:46 +08:00
}],
enabled_count : 1 ,
requires_restart : false ,
});
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
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 ();
2026-07-19 23:30:49 +08:00
const savedSecret = screen . getByPlaceholderText ( "Saved secret" );
expect ( savedSecret ). toHaveValue ( "" );
expect ( savedSecret ). toHaveAttribute ( "autocomplete" , "off" );
expect ( savedSecret . closest ( "form" )). not . toBeNull ();
2026-07-13 13:11:46 +08:00
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 ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/nanobot-features/enable?name=discord" ,
expect . objectContaining ({ headers : { Authorization : "Bearer tok" } }),
),
);
});
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" ,
2026-07-19 23:30:49 +08:00
webui : "webui/index.ts" ,
2026-07-13 13:11:46 +08:00
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 ,
2026-07-19 23:30:49 +08:00
webui : [ "feishu" , "weixin" ]. includes ( name ) ? "webui/index.tsx" : "webui/index.ts" ,
2026-07-13 13:11:46 +08:00
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 ,
2026-07-19 23:30:49 +08:00
settings_visible : false ,
2026-07-13 13:11:46 +08:00
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 ),
2026-07-19 23:30:49 +08:00
webui : name === "feishu" ? "webui/index.tsx" : "webui/index.ts" ,
2026-07-13 13:11:46 +08:00
type : "channel" ,
enabled : true ,
installed : true ,
ready : true ,
status : "enabled" ,
install_supported : true ,
requires_restart : true ,
2026-07-19 23:30:49 +08:00
setup : channelSetupContract ( name as "email" | "feishu" | "matrix" | "qq" ),
2026-07-13 13:11:46 +08:00
})),
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 ();
});
2026-07-03 18:17:52 +08:00
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" ,
2026-07-19 23:30:49 +08:00
capabilities : [ "always_enabled" ],
webui : "webui/index.ts" ,
2026-07-03 18:17:52 +08:00
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 );
2026-07-13 13:11:46 +08:00
renderSettingsView ({ initialSection : "channels" });
2026-07-03 18:17:52 +08:00
2026-07-13 13:11:46 +08:00
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" );
2026-07-03 18:17:52 +08:00
expect ( fetchMock ). not . toHaveBeenCalledWith (
"/api/settings/nanobot-features/disable?name=websocket" ,
expect . anything (),
);
});
2026-05-29 03:42:53 +08:00
it ( "publishes the latest settings payload to the shell" , async () => {
const payload = settingsPayload ();
const onSettingsChange = vi . fn ();
vi . stubGlobal (
"fetch" ,
vi . fn ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
if ( url === "/api/settings" ) return jsonResponse ( payload );
if ( url === "/api/settings/cli-apps" ) {
return jsonResponse ({ apps : [], installed_count : 0 });
}
if ( url === "/api/settings/mcp-presets" ) {
return jsonResponse ({ presets : [], installed_count : 0 });
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
}),
);
renderSettingsView ({ onSettingsChange });
await waitFor (() => expect ( onSettingsChange ). toHaveBeenCalledWith ( payload ));
});
2026-06-18 18:30:25 +08:00
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 ();
2026-07-13 13:11:46 +08:00
expect ( await screen . findByText ( "No tools match this view." )). toBeInTheDocument ();
2026-06-18 18:30:25 +08:00
expect ( screen . queryByText ( "Loading Apps..." )). not . toBeInTheDocument ();
});
2026-06-06 19:49:33 +08:00
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 ();
});
2026-07-30 10:25:22 +08:00
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 ;
});
});
2026-06-08 14:29:31 +08:00
it ( "aligns token activity days with the configured timezone" , async () => {
vi . useFakeTimers ();
vi . setSystemTime ( new Date ( "2026-06-02T18:00:00Z" ));
2026-06-08 14:49:15 +08:00
const basePayload = settingsPayload ();
2026-06-08 14:29:31 +08:00
const payload : SettingsPayload = {
2026-06-08 14:49:15 +08:00
... basePayload ,
2026-06-08 14:29:31 +08:00
agent : {
2026-06-08 14:49:15 +08:00
... basePayload . agent ,
2026-06-08 14:29:31 +08:00
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 ();
});
2026-07-24 00:55:06 +08:00
it ( "keeps generation parameters collapsed until advanced options are opened" , async () => {
2026-05-29 04:23:30 +08:00
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" });
2026-07-24 00:55:06 +08:00
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 );
2026-05-29 04:23:30 +08:00
expect ( await screen . findByText ( "Context window" )). toBeInTheDocument ();
2026-07-24 00:55:06 +08:00
expect ( screen . getByText ( "Temperature" )). toBeInTheDocument ();
2026-05-29 04:23:30 +08:00
expect ( screen . getByRole ( "button" , { name : "64K" })). toBeInTheDocument ();
2026-06-22 16:27:30 +08:00
expect ( screen . getByRole ( "button" , { name : "200K" })). toBeInTheDocument ();
2026-05-29 04:23:30 +08:00
expect ( screen . getByRole ( "button" , { name : "256K" })). toBeInTheDocument ();
2026-07-23 11:55:16 +08:00
expect ( screen . getByRole ( "button" , { name : "500K" })). toBeInTheDocument ();
2026-07-17 16:57:34 +08:00
expect ( screen . getByRole ( "button" , { name : "1M" })). toBeInTheDocument ();
2026-07-24 00:55:06 +08:00
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" );
});
2026-08-07 10:09:05 +08:00
it ( "expands the model preset editor directly below the selected row" , async () => {
2026-08-07 07:55:29 +08:00
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 ];
2026-08-07 10:09:05 +08:00
expect ( screen . queryByTestId ( "model-preset-editor" )). not . toBeInTheDocument ();
expect ( trigger ). toHaveAttribute ( "aria-expanded" , "false" );
2026-08-07 07:55:29 +08:00
fireEvent . click ( trigger );
2026-08-07 10:09:05 +08:00
const editor = screen . getByTestId ( "model-preset-editor" );
2026-08-07 07:55:29 +08:00
expect ( trigger ). toHaveAttribute ( "aria-pressed" , "true" );
2026-08-07 10:09:05 +08:00
expect ( trigger ). toHaveAttribute ( "aria-expanded" , "true" );
expect ( trigger ). toHaveAttribute ( "aria-controls" , "model-preset-editor" );
expect ( row . nextElementSibling ). toBe ( editor );
2026-08-07 11:29:48 +08:00
expect ( editor ). toHaveClass (
"slide-in-from-top-1" ,
"lg:max-w-6xl" ,
"rounded-[18px]" ,
);
2026-08-07 10:09:05 +08:00
expect ( within ( editor ). getByDisplayValue ( "Primary" )). toBeInTheDocument ();
2026-08-07 11:09:03 +08:00
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 ();
2026-08-07 07:55:29 +08:00
2026-08-07 10:09:05 +08:00
fireEvent . click ( trigger );
2026-08-07 07:55:29 +08:00
2026-08-07 10:09:05 +08:00
expect ( trigger ). toHaveAttribute ( "aria-expanded" , "false" );
expect ( screen . queryByTestId ( "model-preset-editor" )). not . toBeInTheDocument ();
2026-08-07 07:55:29 +08:00
});
2026-07-24 00:55:06 +08:00
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 });
}
if ( url . startsWith ( "/api/settings/model-call-order/update?" )) {
return jsonResponse ( updatedPayload );
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
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 (() => {
const saveCall = fetchMock . mock . calls . find (([ input ]) =>
String ( input ). startsWith ( "/api/settings/model-call-order/update?" ),
);
expect ( saveCall ). toBeDefined ();
const url = new URL ( String ( saveCall ? .[ 0 ]), "http://nanobot.test" );
expect ( JSON . parse ( url . searchParams . get ( "order" ) ?? "[]" )). toEqual ([
"backup" ,
"primary" ,
]);
expect ( saveCall ? .[ 1 ]). toEqual (
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
);
});
expect ( screen . queryByRole ( "button" , { name : "Save order" })). not . toBeInTheDocument ();
expect ( screen . getByDisplayValue ( "Primary draft" )). toBeInTheDocument ();
expect ( screen . getByRole ( "button" , { name : "Save preset" })). toBeEnabled ();
});
2026-07-27 18:04:06 +08:00
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 });
}
if ( url . startsWith ( "/api/settings/model-call-order/update?" )) {
return jsonResponse ( updatedPayload );
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
renderSettingsView ({ initialSection : "models" , initialSettings : initialPayload });
const primaryRow = screen . getByTestId ( "model-call-order-row-primary" );
const firstBackupRow = screen . getAllByTestId ( "model-call-order-row-backup" )[ 0 ];
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" ,
]),
);
});
2026-07-24 00:55:06 +08:00
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 });
}
if ( url . startsWith ( "/api/settings/model-call-order/update?" )) {
return {
ok : false ,
status : 500 ,
text : async () => "Order update failed" ,
} as Response ;
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
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 });
}
if ( url . startsWith ( "/api/settings/model-call-order/update?" )) {
return jsonResponse ( orderedPayload );
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
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 (() => {
const orderCall = fetchMock . mock . calls . find (([ input ]) =>
String ( input ). startsWith ( "/api/settings/model-call-order/update?" ),
);
expect ( orderCall ). toBeDefined ();
const url = new URL ( String ( orderCall ? .[ 0 ]), "http://nanobot.test" );
expect ( JSON . parse ( url . searchParams . get ( "order" ) ?? "[]" )). toEqual ([
"primary" ,
"backup" ,
"codex" ,
]);
});
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 });
}
if ( url . startsWith ( "/api/settings/model-configurations/create?" )) {
return jsonResponse ( createdPayload );
}
if ( url . startsWith ( "/api/settings/model-call-order/update?" )) {
return jsonResponse ( orderedPayload );
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
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" },
});
2026-08-04 15:32:29 +08:00
await openPopover ( screen . getByRole ( "button" , { name : "Select model" }));
const modelSearch = await screen . findByRole ( "combobox" , {
2026-07-24 00:55:06 +08:00
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 (() => {
const orderCall = fetchMock . mock . calls . find (([ input ]) =>
String ( input ). startsWith ( "/api/settings/model-call-order/update?" ),
);
expect ( orderCall ). toBeDefined ();
const url = new URL ( String ( orderCall ? .[ 0 ]), "http://nanobot.test" );
expect ( JSON . parse ( url . searchParams . get ( "order" ) ?? "[]" )). toEqual ([
"primary" ,
"backup" ,
"writer" ,
]);
});
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 });
}
if ( url === "/api/settings/model-configurations/migrate" ) {
return jsonResponse ( migratedPayload );
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
renderSettingsView ({ initialSection : "models" , initialSettings : legacyPayload });
fireEvent . click (
await screen . findByRole ( "button" , { name : "Convert to presets" }),
);
await waitFor (() =>
expect ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/model-configurations/migrate" ,
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
),
);
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 ();
2026-05-29 04:23:30 +08:00
});
2026-07-23 11:55:16 +08:00
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 , init? : RequestInit ) => {
const url = String ( input );
if ( url === "/api/settings" ) return jsonResponse ( payload );
if ( url === "/api/settings/provider/oauth-login?provider=xai_grok" ) {
return jsonResponse ( authorization );
}
if (
url ===
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123"
) {
expect ( init ? . headers ). toMatchObject ({
"X-Nanobot-OAuth-Code" : "secret" ,
});
return jsonResponse ( signedIn );
}
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 );
const popup = {
opener : window ,
location : { href : "about:blank" },
close : vi.fn (),
};
vi . stubGlobal ( "open" , vi . fn (() => popup ));
renderSettingsView ({ initialSection : "models" , initialSettings : payload });
2026-07-24 00:55:06 +08:00
await chooseProviderToConfigure ( "xAI Grok" );
2026-07-23 11:55:16 +08:00
fireEvent . click ( screen . getByRole ( "button" , { name : "Sign in" }));
await waitFor (() =>
expect ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/provider/oauth-login?provider=xai_grok" ,
expect . objectContaining ({ headers : { Authorization : "Bearer tok" } }),
),
);
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 ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123" ,
expect . objectContaining ({
headers : expect.objectContaining ({
"X-Nanobot-OAuth-Code" : "secret" ,
}),
}),
),
);
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/provider/oauth-login?provider=xai_grok" ) {
return jsonResponse ( authorization );
}
if (
url ===
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-remote"
) {
return jsonResponse ({
status : "pending" ,
provider : "xai_grok" ,
flow_id : "flow-remote" ,
});
}
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 );
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 });
2026-07-24 00:55:06 +08:00
await chooseProviderToConfigure ( "xAI Grok" );
2026-07-23 11:55:16 +08:00
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 );
}
});
2026-07-30 15:06:34 +08:00
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 , init? : RequestInit ) => {
const url = String ( input );
if ( url === "/api/settings" ) return jsonResponse ( payload );
if ( url === "/api/settings/provider/oauth-login?provider=openai_codex" ) {
return jsonResponse ( authorization );
}
if (
url ===
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex-local"
) {
expect ( init ? . headers ). not . toHaveProperty ( "X-Nanobot-OAuth-Callback" );
return jsonResponse ( signedIn );
}
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 );
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 ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/provider/oauth-login?provider=openai_codex" ,
expect . objectContaining ({ headers : { Authorization : "Bearer tok" } }),
);
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 , init? : RequestInit ) => {
const url = String ( input );
if ( url === "/api/settings" ) return jsonResponse ( payload );
if (
url ===
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true"
) {
return jsonResponse ( authorization );
}
if (
url ===
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex"
) {
const headers = init ? . headers as Record < string , string >;
if ( headers ? .[ "X-Nanobot-OAuth-Callback" ]) {
expect ( headers [ "X-Nanobot-OAuth-Callback" ]). toBe ( callbackUrl );
return jsonResponse ( signedIn );
}
return jsonResponse ({
status : "pending" ,
provider : "openai_codex" ,
flow_id : "flow-codex" ,
});
}
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 );
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 ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex" ,
expect . objectContaining ({
headers : expect.objectContaining ({
"X-Nanobot-OAuth-Callback" : callbackUrl ,
}),
}),
),
);
expect ( await screen . findByText ( "Signed in as acct-codex" )). toBeInTheDocument ();
} finally {
happyWindow . happyDOM . setURL ( originalUrl );
}
});
2026-07-23 11:55:16 +08:00
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" ,
2026-07-24 00:55:06 +08:00
advanced_fields : [ "extra_body" , "proxy" ],
extra_body : null ,
2026-07-23 11:55:16 +08:00
},
{
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 ,
2026-07-24 00:55:06 +08:00
advanced_fields : [ "extra_body" , "proxy" ],
extra_body : null ,
2026-07-23 11:55:16 +08:00
},
];
let payload : SettingsPayload = { ... base , providers };
2026-07-24 00:55:06 +08:00
const fetchMock = vi . fn ( async ( input : RequestInfo | URL , init? : RequestInit ) => {
2026-07-23 11:55:16 +08:00
const url = String ( input );
if ( url === "/api/settings" ) return jsonResponse ( payload );
if ( url . startsWith ( "/api/settings/provider/update?" )) {
const query = new URLSearchParams ( url . split ( "?" )[ 1 ]);
const providerName = query . get ( "provider" );
2026-07-24 00:55:06 +08:00
const headers = init ? . headers as Record < string , string >;
const values = JSON . parse ( decodeURIComponent (
headers [ "X-Nanobot-Provider-Values" ],
)) as {
proxy? : string ;
extraBody? : string ;
};
2026-07-23 11:55:16 +08:00
payload = {
... payload ,
providers : payload.providers.map (( provider ) =>
2026-07-24 00:55:06 +08:00
provider . name === providerName
? {
... provider ,
proxy : values.proxy || null ,
extra_body : values.extraBody ? JSON . parse ( values . extraBody ) : null ,
}
: provider ,
2026-07-23 11:55:16 +08:00
),
};
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 );
renderSettingsView ({ initialSection : "models" , initialSettings : payload });
2026-07-24 00:55:06 +08:00
await chooseProviderToConfigure ( "xAI Grok" );
fireEvent . click ( screen . getByRole ( "button" , { name : "Advanced options" }));
2026-07-23 11:55:16 +08:00
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" ,
2026-07-24 00:55:06 +08:00
"Save advanced changes before signing in." ,
2026-07-23 11:55:16 +08:00
);
2026-07-24 00:55:06 +08:00
fireEvent . click ( screen . getByRole ( "button" , { name : "Save provider" }));
2026-07-23 11:55:16 +08:00
await waitFor (() =>
expect ( fetchMock ). toHaveBeenCalledWith (
2026-07-24 00:55:06 +08:00
"/api/settings/provider/update?provider=xai_grok" ,
expect . objectContaining ({
headers : {
Authorization : "Bearer tok" ,
"X-Nanobot-Provider-Values" : encodeURIComponent ( JSON . stringify ({
extraBody : "" ,
proxy : "http://127.0.0.1:7890" ,
})),
},
}),
2026-07-23 11:55:16 +08:00
),
);
await waitFor (() => expect ( screen . getByRole ( "button" , { name : "Sign in" })). toBeEnabled ());
2026-07-24 00:55:06 +08:00
fireEvent . click ( screen . getByRole ( "button" , { name : "xAI Grok" }));
await chooseProviderToConfigure ( "OpenAI Codex" );
fireEvent . click ( screen . getByRole ( "button" , { name : "Advanced options" }));
2026-07-23 11:55:16 +08:00
const codexProxy = screen . getByLabelText ( "Network proxy" );
expect ( codexProxy ). toHaveValue ( "" );
fireEvent . change ( codexProxy , { target : { value : "http://proxy.example:8080" } });
2026-07-24 00:55:06 +08:00
fireEvent . click ( screen . getByRole ( "button" , { name : "Save provider" }));
2026-07-23 11:55:16 +08:00
await waitFor (() =>
expect ( fetchMock ). toHaveBeenCalledWith (
2026-07-24 00:55:06 +08:00
"/api/settings/provider/update?provider=openai_codex" ,
expect . objectContaining ({
headers : {
Authorization : "Bearer tok" ,
"X-Nanobot-Provider-Values" : encodeURIComponent ( JSON . stringify ({
extraBody : "" ,
proxy : "http://proxy.example:8080" ,
})),
},
}),
2026-07-23 11:55:16 +08:00
),
);
});
2026-08-05 18:26:39 +08:00
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 (... args : [ RequestInfo | URL , RequestInit ? ]) => {
const [ input ] = args ;
const url = String ( input );
if ( url === "/api/settings" ) return jsonResponse ( payload );
if ( url . startsWith ( "/api/settings/provider/update?" )) 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 );
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 ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/provider/update?provider=xai_grok" ,
expect . anything (),
));
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 ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/provider/update?provider=openai_codex" ,
expect . anything (),
));
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 = fetchMock . mock . calls
. filter (([ input ]) => String ( input ). startsWith ( "/api/settings/provider/update?" ))
. map (([ input , init ]) => {
const provider = new URLSearchParams ( String ( input ). split ( "?" )[ 1 ]). get ( "provider" );
const headers = init ? . headers as Record < string , string >;
const values = JSON . parse ( decodeURIComponent (
headers [ "X-Nanobot-Provider-Values" ],
)) as { apiType? : string ; extraBody? : string };
return [ provider , {
...( values . apiType ? { apiType : values.apiType } : {}),
extraBody : JSON.parse ( values . 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 . startsWith ( "/api/settings/provider/update?" )) 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 );
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 = fetchMock . mock . calls . find (
([ input ]) => String ( input ). startsWith ( "/api/settings/provider/update?" ),
);
expect ( updateCall ). toBeTruthy ();
const headers = updateCall ? .[ 1 ] ? . headers as Record < string , string >;
const values = JSON . parse ( decodeURIComponent (
headers [ "X-Nanobot-Provider-Values" ],
)) as { extraBody : string };
expect ( JSON . parse ( values . extraBody )). toEqual ({
metadata : { owner : "legacy-config" },
tools : [{ type : "file_search" , vector_store_ids : [ "vs_legacy" ] }],
});
});
});
2026-07-24 00:55:06 +08:00
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 , init? : RequestInit ) => {
const url = String ( input );
if ( url === "/api/settings" ) return jsonResponse ( payload );
if ( url === "/api/settings/provider/create" ) {
const headers = init ? . headers as Record < string , string >;
const values = JSON . parse ( decodeURIComponent (
headers [ "X-Nanobot-Provider-Values" ],
)) as 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 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 );
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" }));
2026-08-04 17:53:02 +08:00
fireEvent . click ( await screen . findByRole ( "menuitem" , { name : "enable_thinking" }));
2026-07-24 00:55:06 +08:00
fireEvent . click ( screen . getByRole ( "button" , { name : "Save provider" }));
await waitFor (() => {
const createCall = fetchMock . mock . calls . find (
([ input ]) => String ( input ) === "/api/settings/provider/create" ,
);
expect ( createCall ). toBeTruthy ();
const headers = createCall ? .[ 1 ] ? . headers as Record < string , string >;
expect ( JSON . parse ( decodeURIComponent (
headers [ "X-Nanobot-Provider-Values" ],
))). 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 ();
});
2026-07-17 13:02:49 +08:00
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" }));
2026-08-04 17:53:02 +08:00
fireEvent . click ( await screen . findByRole ( "menuitem" , { name : "Gemini" }));
2026-07-17 13:02:49 +08:00
expect ( await screen . findByRole ( "button" , { name : "gemini-2.5-flash-image" })). toBeInTheDocument ();
2026-08-04 15:32:29 +08:00
await openPopover ( screen . getByRole ( "button" , { name : "gemini-2.5-flash-image" }));
fireEvent . click ( await screen . findByRole ( "option" , { name : "imagen-4.0-generate-001" }));
2026-07-17 13:02:49 +08:00
await waitFor (() =>
expect ( screen . getByRole ( "button" , { name : "imagen-4.0-generate-001" })). toBeInTheDocument (),
);
2026-08-04 15:32:29 +08:00
await openPopover ( screen . getByRole ( "button" , { name : "imagen-4.0-generate-001" }));
const modelInput = await screen . findByRole ( "combobox" , { name : "Search or type model ID" });
2026-07-17 18:52:15 +08:00
fireEvent . change ( modelInput , { target : { value : "imagen-5-preview" } });
2026-08-04 15:32:29 +08:00
fireEvent . click ( await screen . findByRole ( "option" , { name : "Use “imagen-5-preview”" }));
2026-07-17 18:52:15 +08:00
expect ( await screen . findByRole ( "button" , { name : "imagen-5-preview" })). toBeInTheDocument ();
2026-07-17 13:02:49 +08:00
fireEvent . pointerDown ( screen . getByRole ( "button" , { name : "Gemini" }));
2026-08-04 17:53:02 +08:00
fireEvent . click ( await screen . findByRole ( "menuitem" , { name : "Custom" }));
2026-07-17 18:52:15 +08:00
expect ( screen . getByRole ( "button" , { name : "imagen-5-preview" })). toBeInTheDocument ();
2026-08-04 15:32:29 +08:00
await openPopover ( screen . getByRole ( "button" , { name : "imagen-5-preview" }));
const customProviderInput = await screen . findByRole ( "combobox" , {
2026-07-17 18:52:15 +08:00
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 ();
2026-07-17 13:02:49 +08:00
});
2026-07-24 00:55:06 +08:00
it ( "does not expose the synthetic default configuration as a WebUI preset" , async () => {
2026-07-13 13:11:46 +08:00
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 ],
2026-07-24 00:55:06 +08:00
name : "default" ,
label : "Default" ,
2026-07-13 13:11:46 +08:00
active : false ,
2026-07-24 00:55:06 +08:00
is_default : true ,
2026-07-13 13:11:46 +08:00
model : "openai-codex/gpt-5.5" ,
provider : "openai_codex" ,
2026-07-24 00:55:06 +08:00
resolved_provider : "openai_codex" ,
2026-07-13 13:11:46 +08:00
},
{
... base . model_presets [ 0 ],
name : "fast" ,
label : "fast" ,
active : true ,
is_default : false ,
model : "MiniMax-M3" ,
provider : "minimax_anthropic" ,
2026-07-24 00:55:06 +08:00
resolved_provider : "minimax_anthropic" ,
2026-07-13 13:11:46 +08:00
},
],
2026-07-24 00:55:06 +08:00
model_call_order : [ "fast" ],
2026-07-13 13:11:46 +08:00
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 });
2026-07-24 00:55:06 +08:00
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 ();
});
2026-07-13 13:11:46 +08:00
2026-07-24 00:55:06 +08:00
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 ();
2026-07-13 13:11:46 +08:00
});
2026-06-12 10:16:54 +08:00
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 ,
}),
});
2026-07-24 00:55:06 +08:00
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 ();
2026-06-12 10:16:54 +08:00
});
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-..." ,
}),
});
2026-07-24 00:55:06 +08:00
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 ();
2026-06-12 10:16:54 +08:00
});
2026-06-06 19:49:33 +08:00
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" });
2026-07-24 00:55:06 +08:00
expect ( await screen . findByText ( "Provider setup required" )). toBeInTheDocument ();
await togglePresetEditor ();
expect ( screen . getAllByText ( /Sign in before saving/ ). length ). toBeGreaterThan ( 0 );
2026-06-06 19:49:33 +08:00
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" });
2026-07-24 00:55:06 +08:00
await togglePresetEditor ();
2026-06-06 19:49:33 +08:00
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 );
2026-08-04 17:53:02 +08:00
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 ();
2026-06-06 19:49:33 +08:00
});
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" });
2026-07-24 00:55:06 +08:00
await togglePresetEditor ();
2026-08-04 15:32:29 +08:00
await openPopover ( await screen . findByRole ( "button" , { name : /Select model/i }));
2026-06-06 19:49:33 +08:00
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" });
2026-07-24 00:55:06 +08:00
await togglePresetEditor ();
2026-06-06 19:49:33 +08:00
const modelButtons = await screen . findAllByRole ( "button" , { name : /open-codex\/gpt-5\.5/i });
2026-08-04 15:32:29 +08:00
await openPopover ( modelButtons [ modelButtons . length - 1 ]);
2026-06-06 19:49:33 +08:00
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 );
});
2026-07-13 13:11:46 +08:00
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 });
2026-07-24 00:55:06 +08:00
await togglePresetEditor ();
2026-07-13 13:11:46 +08:00
const modelButtons = await screen . findAllByRole ( "button" , {
name : /openai-codex\/gpt-5\.5/i ,
});
2026-08-04 15:32:29 +08:00
await openPopover ( modelButtons [ modelButtons . length - 1 ]);
2026-07-13 13:11:46 +08:00
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" } }),
);
});
2026-07-24 00:55:06 +08:00
it ( "creates presets in the inline editor and can cancel without opening a dialog" , async () => {
2026-05-31 17:00:42 +08:00
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" });
2026-07-24 00:55:06 +08:00
fireEvent . click ( await screen . findByRole ( "button" , { name : "New model preset" }));
2026-05-31 17:00:42 +08:00
2026-07-24 00:55:06 +08:00
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 ();
2026-05-31 17:00:42 +08:00
fireEvent . click ( screen . getByRole ( "button" , { name : "Cancel" }));
2026-07-24 00:55:06 +08:00
expect ( screen . queryByDisplayValue ( "Primary" )). not . toBeInTheDocument ();
expect ( screen . queryByText ( "Edit preset" )). not . toBeInTheDocument ();
2026-05-31 17:00:42 +08:00
expect ( document . body . style . pointerEvents ). not . toBe ( "none" );
2026-07-24 00:55:06 +08:00
fireEvent . click ( screen . getByRole ( "button" , { name : "New model preset" }));
expect ( await screen . findByPlaceholderText ( "Fast writing" )). toHaveValue ( "" );
2026-05-31 17:00:42 +08:00
});
2026-05-30 23:45:26 +08:00
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" ,
2026-07-24 00:55:06 +08:00
temperature : 0.4 ,
2026-05-30 23:45:26 +08:00
},
model_presets : [
{
2026-07-24 00:55:06 +08:00
... payload . model_presets [ 0 ],
model : "deepseek-reasoner" ,
temperature : 0.4 ,
reasoning_effort : "provider-native-mode" ,
},
2026-05-30 23:45:26 +08:00
],
};
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 ,
});
}
2026-07-24 00:55:06 +08:00
if ( url . startsWith ( "/api/settings/model-configurations/update?" )) {
2026-05-30 23:45:26 +08:00
return jsonResponse ( updatedPayload );
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
renderSettingsView ({ initialSection : "models" });
2026-07-24 00:55:06 +08:00
await togglePresetEditor ();
2026-05-30 23:45:26 +08:00
const modelButtons = await screen . findAllByRole ( "button" , { name : /deepseek-chat/i });
2026-08-04 15:32:29 +08:00
await openPopover ( modelButtons [ modelButtons . length - 1 ]);
2026-05-30 23:45:26 +08:00
await screen . findByText ( "deepseek-reasoner" );
fireEvent . click ( screen . getAllByText ( "deepseek-reasoner" )[ 0 ]);
2026-07-24 00:55:06 +08:00
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" }));
2026-05-30 23:45:26 +08:00
await waitFor (() =>
expect ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/provider-models?provider=deepseek" ,
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
),
);
2026-07-24 00:55:06 +08:00
await waitFor (() => {
const saveCall = fetchMock . mock . calls . find (([ input ]) =>
String ( input ). startsWith ( "/api/settings/model-configurations/update?" ),
);
expect ( saveCall ). toBeDefined ();
const url = new URL ( String ( saveCall ? .[ 0 ]), "http://nanobot.test" );
expect ( Object . fromEntries ( url . searchParams )). toEqual ({
name : "primary" ,
model : "deepseek-reasoner" ,
reasoning_effort : "provider-native-mode" ,
temperature : "0.4" ,
});
expect ( saveCall ? .[ 1 ]). toEqual (
2026-05-30 23:45:26 +08:00
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
2026-07-24 00:55:06 +08:00
);
});
2026-05-30 23:45:26 +08:00
});
2026-05-29 03:42:53 +08:00
it ( "saves network safety without exposing technical SSRF copy" , async () => {
const payload = settingsPayload ();
const fetchMock = vi . fn ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
if ( url === "/api/settings" ) return jsonResponse ( payload );
if ( url === "/api/settings/cli-apps" ) {
return jsonResponse ({ apps : [], installed_count : 0 });
}
if ( url === "/api/settings/mcp-presets" ) {
return jsonResponse ({ presets : [], installed_count : 0 });
}
if ( url === "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default" ) {
return jsonResponse ({
... payload ,
advanced : { ... payload . advanced , webui_allow_local_service_access : false },
requires_restart : true ,
restart_required_sections : [ "runtime" ],
});
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
renderSettingsView ({ initialSection : "advanced" });
expect ( await screen . findByText ( "Web safety" )). toBeInTheDocument ();
expect ( screen . queryByText ( /SSRF/i )). not . toBeInTheDocument ();
expect ( screen . queryByText ( "Private Service Protection" )). not . toBeInTheDocument ();
expect ( screen . getByText ( "Default access" )). toBeInTheDocument ();
expect ( screen . queryByRole ( "button" , { name : "Restricted" })). not . toBeInTheDocument ();
expect ( screen . getByRole ( "button" , { name : "Default Permission" })). toBeInTheDocument ();
expect ( screen . getByRole ( "button" , { name : "Full Access" })). toBeInTheDocument ();
fireEvent . click ( screen . getByRole ( "switch" , { name : "Local services" }));
fireEvent . click ( screen . getByRole ( "button" , { name : "Save" }));
await waitFor (() =>
expect ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default" ,
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
),
);
});
2026-06-21 14:50:01 +08:00
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 });
if (
url ===
"/api/settings/web-search/update?provider=keenable&max_results=5&timeout=30&use_jina_reader=true"
) {
return jsonResponse ( updatedPayload );
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
renderSettingsView ({ initialSection : "browser" });
fireEvent . pointerDown ( await screen . findByRole ( "button" , { name : /DuckDuckGo/ }));
2026-08-04 17:53:02 +08:00
fireEvent . click ( await screen . findByRole ( "menuitem" , { name : "Keenable" }));
2026-06-21 14:50:01 +08:00
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 ( fetchMock ). toHaveBeenCalledWith (
"/api/settings/web-search/update?provider=keenable&max_results=5&timeout=30&use_jina_reader=true" ,
expect . objectContaining ({
headers : { Authorization : "Bearer tok" },
}),
),
);
});
2026-05-29 03:42:53 +08:00
it ( "uses native host safety copy on the native surface" , async () => {
const payload = {
... settingsPayload (),
surface : "native" as const ,
runtime_surface : "native" as const ,
};
vi . stubGlobal (
"fetch" ,
vi . fn ( async ( input : RequestInfo | URL ) => {
const url = String ( input );
if ( url === "/api/settings" ) return jsonResponse ( payload );
if ( url === "/api/settings/cli-apps" ) return jsonResponse ({ apps : [], installed_count : 0 });
if ( url === "/api/settings/mcp-presets" ) return jsonResponse ({ presets : [], installed_count : 0 });
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
}),
);
renderSettingsView ({ initialSection : "advanced" });
expect ( await screen . findByText ( "App safety" )). toBeInTheDocument ();
expect ( screen . queryByText ( "Web safety" )). not . toBeInTheDocument ();
expect ( screen . getByText ( "Allow Full Access shell commands to reach services on this Mac." )). toBeInTheDocument ();
});
2026-06-06 19:49:33 +08:00
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 });
if ( url === "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default" ) {
return jsonResponse ( restartedPayload );
}
return { ok : false , status : 404 , json : async () => ({}) } as Response ;
});
vi . stubGlobal ( "fetch" , fetchMock );
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" },
}),
),
);
});
2026-05-25 20:07:02 +08:00
});