fix(webui): move mutations to authenticated websocket requests
This commit is contained in:
+310
-359
@@ -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 () => {
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user