fix(webui): move mutations to authenticated websocket requests

This commit is contained in:
chengyongru
2026-08-10 16:23:47 +08:00
committed by chengyongru
parent 71a99b0780
commit 5d733b1c7c
25 changed files with 2458 additions and 2056 deletions
+310 -359
View File
@@ -59,8 +59,19 @@ import {
validateChannel,
} from "@/lib/api";
const requestMutation = vi.fn();
const mutationTransport = {
requestMutation: <T>(
action: string,
payload?: Record<string, unknown>,
timeoutMs?: number,
) => requestMutation(action, payload, timeoutMs) as Promise<T>,
};
describe("webui API helpers", () => {
beforeEach(() => {
requestMutation.mockReset();
requestMutation.mockResolvedValue({});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
@@ -184,88 +195,74 @@ describe("webui API helpers", () => {
it("validates channel settings with form values", async () => {
await validateChannel(
"tok",
mutationTransport,
"slack",
{ "channels.slack.botToken": "xoxb-test" },
{ instanceId: "default" },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/channels/validate?name=slack&instance_id=default",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-Channel-Values": JSON.stringify({
"channels.slack.botToken": "xoxb-test",
}),
}),
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST" }),
expect(requestMutation).toHaveBeenCalledWith(
"settings.channel.validate",
{
name: "slack",
instance_id: "default",
values: { "channels.slack.botToken": "xoxb-test" },
},
20_000,
);
expect(fetch).not.toHaveBeenCalled();
});
it("configures channels through the WebSocket HTTP shim", async () => {
it("configures channels through the authenticated WebSocket", async () => {
await configureChannel(
"tok",
mutationTransport,
"discord",
{ "channels.discord.token": "saved-secret" },
{ enable: true },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/channels/configure?name=discord&enable=true",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-Channel-Values": JSON.stringify({
"channels.discord.token": "saved-secret",
}),
}),
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST" }),
expect(requestMutation).toHaveBeenCalledWith(
"settings.channel.configure",
{
name: "discord",
enable: true,
values: { "channels.discord.token": "saved-secret" },
},
150_000,
);
expect(fetch).not.toHaveBeenCalled();
});
it("serializes channel QR connect helpers", async () => {
await startChannelConnect("tok", "weixin", { force: true });
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/start?force=true",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
it("serializes channel QR connect request envelopes", async () => {
await startChannelConnect(mutationTransport, "weixin", { force: true });
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.channel.connect.start",
{ channel: "weixin", force: true },
150_000,
);
await pollChannelConnect("tok", "weixin", "session+/=");
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await pollChannelConnect(mutationTransport, "weixin", "session+/=");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.channel.connect.poll",
{ channel: "weixin", session_id: "session+/=" },
150_000,
);
await cancelChannelConnect("tok", "weixin", "session+/=");
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await cancelChannelConnect(mutationTransport, "weixin", "session+/=");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.channel.connect.cancel",
{ channel: "weixin", session_id: "session+/=" },
20_000,
);
});
it("serializes workspace automation actions", async () => {
await runAutomationAction("tok", "disable", "job 1/2");
await runAutomationAction(mutationTransport, "disable", "job 1/2");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/disable?id=job+1%2F2",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"automation.disable",
{ id: "job 1/2" },
20_000,
);
});
@@ -275,19 +272,14 @@ describe("webui API helpers", () => {
message: "Ask 今日 quiz",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
} as const;
await updateAutomation("tok", "job 1/2", values);
await updateAutomation(mutationTransport, "job 1/2", values);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/update?id=job+1%2F2",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)),
},
}),
expect(requestMutation).toHaveBeenCalledWith(
"automation.update",
{ id: "job 1/2", values },
20_000,
);
const header = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
expect(header["X-Nanobot-Automation-Values"]).not.toContain("每日");
expect(fetch).not.toHaveBeenCalled();
});
it("fetches the WebUI skill summary", async () => {
@@ -348,66 +340,66 @@ describe("webui API helpers", () => {
);
});
it("encodes provider install coordinates", async () => {
it("sends provider install coordinates without placing them in a URL", async () => {
await installMarketplaceSkill(
"tok",
mutationTransport,
"skillhub",
"@tencent/skills",
"ima-skills",
"1.1.8",
);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/install?provider=skillhub&source=%40tencent%2Fskills&skill=ima-skills&version=1.1.8",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"skill.install",
{
provider: "skillhub",
source: "@tencent/skills",
skill: "ima-skills",
version: "1.1.8",
},
150_000,
);
});
it("updates and deletes installed skills with encoded names", async () => {
await updateSkillEnabled("tok", "custom skill", false);
it("updates and deletes installed skills over the WebSocket", async () => {
await updateSkillEnabled(mutationTransport, "custom skill", false);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/update?name=custom+skill&enabled=false",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenLastCalledWith(
"skill.update",
{ name: "custom skill", enabled: false },
20_000,
);
await deleteSkill("tok", "custom skill");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/delete?name=custom+skill",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await deleteSkill(mutationTransport, "custom skill");
expect(requestMutation).toHaveBeenLastCalledWith(
"skill.delete",
{ name: "custom skill" },
20_000,
);
});
it("percent-encodes websocket keys when deleting a session", async () => {
await deleteSession("tok", "websocket:chat-1");
it("sends the session key in a mutation payload", async () => {
await deleteSession(mutationTransport, "websocket:chat-1");
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/delete",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"session.delete",
{ key: "websocket:chat-1" },
20_000,
);
});
it("passes the automation cascade flag when deleting a session", async () => {
await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true });
await deleteSession(mutationTransport, "websocket:chat-1", { deleteAutomations: true });
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/delete?delete_automations=true",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"session.delete",
{ key: "websocket:chat-1", delete_automations: true },
20_000,
);
});
it("serializes settings updates as a narrow query string", async () => {
await updateSettings("tok", {
it("serializes settings updates as a narrow mutation payload", async () => {
await updateSettings(mutationTransport, {
modelPreset: "default",
model: "openrouter/test",
provider: "openrouter",
@@ -416,11 +408,17 @@ describe("webui API helpers", () => {
toolHintMaxLength: 120,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&tool_hint_max_length=120",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.agent.update",
{
model_preset: "default",
model: "openrouter/test",
provider: "openrouter",
context_window_tokens: 262144,
timezone: "Asia/Shanghai",
tool_hint_max_length: 120,
},
20_000,
);
});
@@ -436,7 +434,7 @@ describe("webui API helpers", () => {
});
it("serializes model configuration creation", async () => {
await createModelConfiguration("tok", {
await createModelConfiguration(mutationTransport, {
label: "Fast writing",
provider: "openai",
model: "openai/gpt-4.1-mini",
@@ -446,16 +444,23 @@ describe("webui API helpers", () => {
reasoningEffort: "high",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini&max_tokens=4096&context_window_tokens=128000&temperature=0.4&reasoning_effort=high",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.model_configuration.create",
{
label: "Fast writing",
provider: "openai",
model: "openai/gpt-4.1-mini",
max_tokens: 4096,
context_window_tokens: 128000,
temperature: 0.4,
reasoning_effort: "high",
},
20_000,
);
});
it("serializes model configuration updates", async () => {
await updateModelConfiguration("tok", {
await updateModelConfiguration(mutationTransport, {
name: "codex",
label: "Codex",
provider: "openai_codex",
@@ -466,42 +471,47 @@ describe("webui API helpers", () => {
reasoningEffort: null,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&max_tokens=8192&context_window_tokens=65536&temperature=0&reasoning_effort=",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.model_configuration.update",
{
name: "codex",
label: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0,
reasoning_effort: "",
},
20_000,
);
});
it("serializes model preset deletion and migration", async () => {
await deleteModelConfiguration("tok", "spare");
await migrateModelConfigurations("tok");
await deleteModelConfiguration(mutationTransport, "spare");
await migrateModelConfigurations(mutationTransport);
expect(fetch).toHaveBeenNthCalledWith(
expect(requestMutation).toHaveBeenNthCalledWith(
1,
"/api/settings/model-configurations/delete?name=spare",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
"settings.model_configuration.delete",
{ name: "spare" },
20_000,
);
expect(fetch).toHaveBeenNthCalledWith(
expect(requestMutation).toHaveBeenNthCalledWith(
2,
"/api/settings/model-configurations/migrate",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
"settings.model_configuration.migrate",
{},
20_000,
);
});
it("serializes model call order as an ordered JSON array", async () => {
await updateModelCallOrder("tok", ["backup", "primary"]);
await updateModelCallOrder(mutationTransport, ["backup", "primary"]);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-call-order/update?order=%5B%22backup%22%2C%22primary%22%5D",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.model_call_order.update",
{ order: ["backup", "primary"] },
20_000,
);
});
@@ -516,28 +526,20 @@ describe("webui API helpers", () => {
}),
);
await expect(
updateModelConfiguration("tok", {
name: "codex",
model: "openai-codex/gpt-5.5",
}),
).rejects.toMatchObject({
await expect(fetchApiService("tok")).rejects.toMatchObject({
status: 200,
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
});
});
it("surfaces API error response bodies", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () => "npm error ENOTEMPTY",
}),
it("surfaces correlated WebSocket mutation errors", async () => {
requestMutation.mockRejectedValueOnce(
Object.assign(new Error("npm error ENOTEMPTY"), { status: 500 }),
);
await expect(runCliAppAction("tok", "install", "hyperframes")).rejects.toMatchObject({
await expect(
runCliAppAction(mutationTransport, "install", "hyperframes"),
).rejects.toMatchObject({
status: 500,
message: "npm error ENOTEMPTY",
});
@@ -555,50 +557,45 @@ describe("webui API helpers", () => {
await pending;
});
it("serializes provider settings updates without returning secrets", async () => {
await updateProviderSettings("tok", {
it("keeps provider secrets in the WebSocket payload", async () => {
await updateProviderSettings(mutationTransport, {
provider: "openrouter",
apiKey: "sk-or-test",
apiBase: "https://openrouter.ai/api/v1",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=openrouter",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
apiKey: "sk-or-test",
apiBase: "https://openrouter.ai/api/v1",
})),
},
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.provider.update",
{
provider: "openrouter",
apiKey: "sk-or-test",
apiBase: "https://openrouter.ai/api/v1",
},
20_000,
);
expect(fetch).not.toHaveBeenCalled();
});
it("serializes OAuth provider advanced settings", async () => {
await updateProviderSettings("tok", {
await updateProviderSettings(mutationTransport, {
provider: "xai_grok",
proxy: "http://127.0.0.1:7890",
extraBody: '{"tools":[]}',
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=xai_grok",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
proxy: "http://127.0.0.1:7890",
extraBody: '{"tools":[]}',
})),
},
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.provider.update",
{
provider: "xai_grok",
proxy: "http://127.0.0.1:7890",
extraBody: '{"tools":[]}',
},
20_000,
);
});
it("serializes custom provider creation with advanced settings", async () => {
await createProviderSettings("tok", {
const update = {
name: "Company Gateway",
apiKey: "sk-company",
apiBase: "https://gateway.example/v1",
@@ -607,25 +604,13 @@ describe("webui API helpers", () => {
extraQuery: '{"api-version":"2026-01-01"}',
proxy: "http://127.0.0.1:7890",
thinkingStyle: "enable_thinking",
});
};
await createProviderSettings(mutationTransport, update);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/create",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
name: "Company Gateway",
apiKey: "sk-company",
apiBase: "https://gateway.example/v1",
extraHeaders: '{"X-Tenant":"engineering"}',
extraBody: '{"service_tier":"priority"}',
extraQuery: '{"api-version":"2026-01-01"}',
proxy: "http://127.0.0.1:7890",
thinkingStyle: "enable_thinking",
})),
},
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.provider.create",
update,
20_000,
);
});
@@ -641,74 +626,65 @@ describe("webui API helpers", () => {
});
it("serializes provider OAuth login and logout actions", async () => {
await loginProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await loginProviderOAuth(mutationTransport, "openai_codex");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_login",
{ provider: "openai_codex" },
20_000,
);
await loginProviderOAuth("tok", "openai_codex", "", true);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await loginProviderOAuth(mutationTransport, "openai_codex", true);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_login",
{ provider: "openai_codex", remote_browser: true },
20_000,
);
await completeProviderOAuth("tok", "xai_grok", "flow-123");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await completeProviderOAuth(mutationTransport, "xai_grok", "flow-123");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_complete",
{ provider: "xai_grok", flow_id: "flow-123" },
20_000,
);
await completeProviderOAuth(
"tok",
mutationTransport,
"xai_grok",
"flow-123",
"secret",
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-OAuth-Code": "secret",
},
}),
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_complete",
{ provider: "xai_grok", flow_id: "flow-123", authorization_response: "secret" },
20_000,
);
await completeProviderOAuth(
"tok",
mutationTransport,
"openai_codex",
"flow-codex",
"http://localhost:1455/auth/callback?code=secret&state=test",
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-OAuth-Callback":
"http://localhost:1455/auth/callback?code=secret&state=test",
},
}),
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_complete",
{
provider: "openai_codex",
flow_id: "flow-codex",
authorization_response: "http://localhost:1455/auth/callback?code=secret&state=test",
},
20_000,
);
await logoutProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-logout?provider=openai_codex",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await logoutProviderOAuth(mutationTransport, "openai_codex");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_logout",
{ provider: "openai_codex" },
20_000,
);
});
it("serializes web search settings updates", async () => {
await updateWebSearchSettings("tok", {
await updateWebSearchSettings(mutationTransport, {
provider: "searxng",
baseUrl: "https://search.example.com",
maxResults: 8,
@@ -716,30 +692,37 @@ describe("webui API helpers", () => {
useJinaReader: false,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com&max_results=8&timeout=45&use_jina_reader=false",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.web_search.update",
{
provider: "searxng",
base_url: "https://search.example.com",
max_results: 8,
timeout: 45,
use_jina_reader: false,
},
20_000,
);
});
it("serializes network safety settings updates", async () => {
await updateNetworkSafetySettings("tok", {
await updateNetworkSafetySettings(mutationTransport, {
webuiAllowLocalServiceAccess: false,
webuiDefaultAccessMode: "full",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.network_safety.update",
{
webui_allow_local_service_access: false,
webui_default_access_mode: "full",
},
20_000,
);
});
it("serializes image generation settings updates", async () => {
await updateImageGenerationSettings("tok", {
await updateImageGenerationSettings(mutationTransport, {
enabled: true,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
@@ -748,11 +731,17 @@ describe("webui API helpers", () => {
maxImagesPerTurn: 3,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/image-generation/update?enabled=true&provider=openrouter&model=openai%2Fgpt-5.4-image-2&default_aspect_ratio=16%3A9&default_image_size=2K&max_images_per_turn=3",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.image_generation.update",
{
enabled: true,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "16:9",
default_image_size: "2K",
max_images_per_turn: 3,
},
20_000,
);
});
@@ -774,12 +763,11 @@ describe("webui API helpers", () => {
}),
);
await runCliAppAction("tok", "install", "gimp");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/cli-apps/install?name=gimp",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await runCliAppAction(mutationTransport, "install", "gimp");
expect(requestMutation).toHaveBeenCalledWith(
"settings.cli_app.install",
{ name: "gimp" },
150_000,
);
});
@@ -819,20 +807,18 @@ describe("webui API helpers", () => {
}),
);
await enableNanobotFeature("tok", "matrix");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/nanobot-features/enable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await enableNanobotFeature(mutationTransport, "matrix");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.feature.enable",
{ name: "matrix" },
150_000,
);
await disableNanobotFeature("tok", "matrix");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/nanobot-features/disable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await disableNanobotFeature(mutationTransport, "matrix");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.feature.disable",
{ name: "matrix" },
20_000,
);
});
@@ -843,34 +829,31 @@ describe("webui API helpers", () => {
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 });
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
await startApiService(
mutationTransport,
{ host: "127.0.0.1", port: 8900, timeout: 120 },
);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.api_service.start",
{ host: "127.0.0.1", port: 8900, timeout: 120 },
150_000,
);
await startApiService(
"tok",
mutationTransport,
{ host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-API-Service-Values": JSON.stringify({ api_key: "secret-token" }),
},
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.stringContaining("secret-token"),
expect.anything(),
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.api_service.start",
{ host: "0.0.0.0", port: 8900, timeout: 120, api_key: "secret-token" },
150_000,
);
await stopApiService("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/stop",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
await stopApiService(mutationTransport);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.api_service.stop",
{},
20_000,
);
});
@@ -891,71 +874,46 @@ describe("webui API helpers", () => {
}),
);
await runMcpPresetAction("tok", "enable", "browserbase", {
await runMcpPresetAction(mutationTransport, "enable", "browserbase", {
browserbase_api_key: "bb_live_test",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/mcp-presets/enable?name=browserbase",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-MCP-Values": JSON.stringify({
browserbase_api_key: "bb_live_test",
}),
}),
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.mcp.enable",
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
20_000,
);
});
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
await saveCustomMcpServer("tok", {
const custom = {
name: "docs",
transport: "stdio",
command: "npx",
args: '["-y","docs-mcp"]',
env: '{"API_KEY":"secret"}',
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/mcp-presets/custom",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-MCP-Values": JSON.stringify({
name: "docs",
transport: "stdio",
command: "npx",
args: '["-y","docs-mcp"]',
env: '{"API_KEY":"secret"}',
}),
}),
}),
};
await saveCustomMcpServer(mutationTransport, custom);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.mcp.custom",
custom,
20_000,
);
await importMcpConfig("tok", '{"mcpServers":{"docs":{"command":"npx"}}}');
expect(fetch).toHaveBeenCalledWith(
"/api/settings/mcp-presets/import",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-MCP-Values": JSON.stringify({
config: '{"mcpServers":{"docs":{"command":"npx"}}}',
}),
}),
}),
await importMcpConfig(
mutationTransport,
'{"mcpServers":{"docs":{"command":"npx"}}}',
);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.mcp.import",
{ config: '{"mcpServers":{"docs":{"command":"npx"}}}' },
20_000,
);
await updateMcpServerTools("tok", "docs", ["search", "fetch"]);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/mcp-presets/tools",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-MCP-Values": JSON.stringify({
name: "docs",
enabled_tools: ["search", "fetch"],
}),
}),
}),
await updateMcpServerTools(mutationTransport, "docs", ["search", "fetch"]);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.mcp.tools",
{ name: "docs", enabled_tools: ["search", "fetch"] },
20_000,
);
});
@@ -991,19 +949,12 @@ describe("webui API helpers", () => {
}),
);
await updateSidebarState("tok", state);
const [url, init] = vi.mocked(fetch).mock.calls.at(-1)!;
expect(String(url).startsWith("/api/webui/sidebar-state/update?")).toBe(true);
expect(init).toEqual(expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}));
const encodedState = new URLSearchParams(String(url).split("?", 2)[1]).get("state");
expect(encodedState).toBeTruthy();
expect(JSON.parse(encodedState ?? "{}")).toMatchObject({
pinned_keys: ["websocket:chat-1"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
});
await updateSidebarState(mutationTransport, state);
expect(requestMutation).toHaveBeenCalledWith(
"sidebar.update",
{ state },
20_000,
);
});
it("fetches workspace project state", async () => {
+67 -74
View File
@@ -19,6 +19,7 @@ const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const setSidebarStateSpy = vi.fn();
const requestMutationSpy = vi.fn();
const discardTemporaryChatSpy = vi.fn();
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
const sendMessageSpy = vi.fn();
@@ -242,6 +243,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
newTemporaryChat = newTemporaryChatSpy;
attach = attachSpy;
setSidebarState = setSidebarStateSpy;
requestMutation = requestMutationSpy;
discardTemporaryChat = discardTemporaryChatSpy;
close = vi.fn();
updateUrl = updateUrlSpy;
@@ -270,7 +272,8 @@ describe("App layout", () => {
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset();
attachSpy.mockReset();
setSidebarStateSpy.mockReset();
setSidebarStateSpy.mockReset().mockResolvedValue({});
requestMutationSpy.mockReset();
discardTemporaryChatSpy.mockReset();
let temporaryChatCounter = 0;
newTemporaryChatSpy.mockImplementation(async () => (
@@ -877,40 +880,36 @@ describe("App layout", () => {
}],
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
},
"/api/webui/skills/update?name=github&enabled=false": {
skills: [
{
name: "cron",
description: "Schedule reminders.",
source: "builtin",
enabled: true,
deletable: false,
available: true,
},
{
name: "github",
description: "Work with GitHub.",
source: "builtin",
enabled: false,
deletable: false,
available: false,
unavailable_reason: "CLI: gh",
},
{
name: "custom-skill",
description: "A workspace skill.",
source: "workspace",
enabled: true,
deletable: true,
available: true,
},
],
last_action: {
name: "github",
enabled: false,
deleted: false,
});
requestMutationSpy.mockResolvedValueOnce({
skills: [
{
name: "cron",
description: "Schedule reminders.",
source: "builtin",
enabled: true,
deletable: false,
available: true,
},
},
{
name: "github",
description: "Work with GitHub.",
source: "builtin",
enabled: false,
deletable: false,
available: false,
unavailable_reason: "CLI: gh",
},
{
name: "custom-skill",
description: "A workspace skill.",
source: "workspace",
enabled: true,
deletable: true,
available: true,
},
],
last_action: { name: "github", enabled: false, deleted: false },
});
render(<App />);
@@ -1010,14 +1009,10 @@ describe("App layout", () => {
},
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
},
"/api/webui/skills/delete?name=custom-skill": {
skills: [],
last_action: {
name: "custom-skill",
enabled: false,
deleted: true,
},
},
});
requestMutationSpy.mockResolvedValueOnce({
skills: [],
last_action: { name: "custom-skill", enabled: false, deleted: true },
});
render(<App />);
@@ -1149,9 +1144,8 @@ describe("App layout", () => {
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
trends: { "acme/agent-skills/react-testing": [] },
},
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing":
() => pendingInstall,
});
requestMutationSpy.mockImplementationOnce(() => pendingInstall);
render(<App />);
@@ -1199,11 +1193,14 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing",
expect.objectContaining({
headers: { Authorization: expect.any(String) },
}),
expect(requestMutationSpy).toHaveBeenCalledWith(
"skill.install",
{
provider: "skills_sh",
source: "acme/agent-skills",
skill: "react-testing",
},
150_000,
);
});
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
@@ -1361,14 +1358,12 @@ describe("App layout", () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": { jobs: [pastOneShot] },
"/api/webui/automations/update?id=past-one-shot": {
jobs: [
{
...pastOneShot,
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
},
],
},
});
requestMutationSpy.mockResolvedValueOnce({
jobs: [{
...pastOneShot,
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
}],
});
render(<App />);
@@ -1394,20 +1389,18 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/update?id=past-one-shot",
expect.any(Object),
expect(requestMutationSpy).toHaveBeenCalledWith(
"automation.update",
{
id: "past-one-shot",
values: {
name: "Past one-shot",
message: "Updated one-shot message",
},
},
20_000,
);
});
const updateCall = vi.mocked(fetch).mock.calls.find(
([url]) => String(url) === "/api/webui/automations/update?id=past-one-shot",
);
expect(updateCall).toBeTruthy();
const headers = updateCall?.[1]?.headers as Record<string, string>;
expect(JSON.parse(decodeURIComponent(headers["X-Nanobot-Automation-Values"]))).toEqual({
name: "Past one-shot",
message: "Updated one-shot message",
});
});
it("keeps long automation details expandable without nested scrolling", async () => {
@@ -1829,6 +1822,9 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
act(() => {
statusHandlers.forEach((handler) => handler("open"));
});
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(),
@@ -2581,17 +2577,14 @@ describe("App layout", () => {
mockFetchRoutes({
"/api/settings": initialSettings,
});
const fetchMock = vi.mocked(fetch);
window.history.replaceState(null, "", "/#/settings?section=runtime");
render(<App />);
expect(await screen.findByText("UTC")).toBeInTheDocument();
expect(
fetchMock.mock.calls.filter(([input]) =>
String(input).startsWith("/api/settings/update?timezone="),
),
).toHaveLength(0);
requestMutationSpy.mock.calls.some(([action]) => action === "settings.agent.update"),
).toBe(false);
expect(screen.queryByRole("heading", { name: "Regional" })).not.toBeInTheDocument();
expect(
screen.queryByText("Used for schedules and time-aware replies."),
+137 -3
View File
@@ -71,6 +71,122 @@ afterEach(() => {
});
describe("NanobotClient", () => {
it("correlates successful WebUI mutation replies by request id", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = client.requestMutation<{ saved: boolean }>(
"settings.provider.update",
{ provider: "openrouter", apiKey: "secret" },
);
const frame = JSON.parse(socket.sent.at(-1) as string);
expect(frame).toMatchObject({
type: "webui_request",
action: "settings.provider.update",
payload: { provider: "openrouter", apiKey: "secret" },
});
expect(frame.request_id).toEqual(expect.any(String));
socket.fakeMessage({
event: "webui_response",
request_id: frame.request_id,
ok: true,
result: { saved: true },
});
await expect(pending).resolves.toEqual({ saved: true });
});
it("surfaces correlated WebUI mutation errors with status", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = client.requestMutation("settings.channel.configure", {});
const requestId = JSON.parse(socket.sent.at(-1) as string).request_id;
socket.fakeMessage({
event: "webui_response",
request_id: requestId,
ok: false,
error: { status: 400, message: "missing channel name" },
});
await expect(pending).rejects.toMatchObject({
status: 400,
message: "missing channel name",
});
});
it("times out WebUI mutations without replaying them", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = expect(
client.requestMutation("skill.install", { skill: "docs" }, 25),
).rejects.toMatchObject({
status: 504,
message: "WebUI request timed out after 25ms",
});
expect(socket.sent).toHaveLength(1);
await vi.advanceTimersByTimeAsync(25);
await pending;
expect(socket.sent).toHaveLength(1);
});
it("rejects in-flight WebUI mutations when the socket closes", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = client.requestMutation("session.delete", {
key: "websocket:chat-1",
});
socket.fakeCloseWithCode(1006);
await expect(pending).rejects.toMatchObject({
status: 503,
message: "Socket closed before WebUI response",
});
});
it("does not queue WebUI mutations before the authenticated socket opens", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
await expect(client.requestMutation("settings.agent.update", {})).rejects.toMatchObject({
status: 503,
message: "WebUI connection is not open",
});
expect(lastSocket().sent).toEqual([]);
});
it("keeps temporary chats out of attachment and reconnect state", async () => {
const client = new NanobotClient({
url: "ws://test",
@@ -1071,7 +1187,7 @@ describe("NanobotClient", () => {
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
});
it("sends large sidebar ordering state outside the HTTP request line", () => {
it("sends large sidebar ordering state as a correlated WebUI request", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -1102,11 +1218,29 @@ describe("NanobotClient", () => {
client.connect();
lastSocket().fakeOpen();
client.setSidebarState(state);
const pending = client.setSidebarState(state);
const [serialized] = lastSocket().sent;
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192);
expect(JSON.parse(serialized)).toEqual({ type: "set_sidebar_state", state });
const request = JSON.parse(serialized) as {
type: string;
request_id: string;
action: string;
payload: { state: SidebarStatePayload };
};
expect(request).toEqual({
type: "webui_request",
request_id: expect.any(String),
action: "sidebar.update",
payload: { state },
});
lastSocket().fakeMessage({
event: "webui_response",
request_id: request.request_id,
ok: true,
result: state,
});
await expect(pending).resolves.toEqual(state);
});
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -109,8 +109,9 @@ describe("useSessions", () => {
]);
vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
const client = fakeClient();
const { result } = renderHook(() => useSessions(), {
wrapper: wrap(fakeClient()),
wrapper: wrap(client),
});
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
@@ -119,7 +120,7 @@ describe("useSessions", () => {
await result.current.deleteChat("websocket:chat-a");
});
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined);
expect(api.deleteSession).toHaveBeenCalledWith(client, "websocket:chat-a", undefined);
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
});