mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 08:42:20 +03:00
746 lines
29 KiB
TypeScript
746 lines
29 KiB
TypeScript
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import i18n from "@/i18n";
|
|
import {
|
|
installSettingsViewTestHooks,
|
|
jsonResponse,
|
|
renderSettingsView,
|
|
requestMutationMock,
|
|
settingsPayload,
|
|
} from "@/tests/settings-test-utils";
|
|
|
|
const xmindMcpPreset = {
|
|
name: "xmind",
|
|
display_name: "Xmind",
|
|
category: "productivity",
|
|
description: "Create, read, and edit cloud mind maps through Xmind.",
|
|
docs_url: "https://xmind.com/user-guide/xmind-mcp",
|
|
transport: "streamableHttp",
|
|
auth: "oauth" as const,
|
|
requires: "Xmind account",
|
|
note: "Connects securely in your browser with Xmind OAuth.",
|
|
install_supported: true,
|
|
installed: false,
|
|
configured: false,
|
|
available: false,
|
|
status: "not_installed",
|
|
logo_url: null,
|
|
brand_color: "#F4B41A",
|
|
required_fields: [],
|
|
connection_summary: "",
|
|
enabled_tools: ["*"],
|
|
source: "preset",
|
|
};
|
|
|
|
describe("SettingsView Apps catalog", () => {
|
|
installSettingsViewTestHooks();
|
|
|
|
it("connects an OAuth MCP from the Apps catalog without manual callback input", async () => {
|
|
let connected = false;
|
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
|
if (url === "/api/settings/cli-apps") {
|
|
return jsonResponse({ apps: [], installed_count: 0 });
|
|
}
|
|
if (url === "/api/settings/mcp-presets") {
|
|
return jsonResponse({
|
|
presets: [connected
|
|
? {
|
|
...xmindMcpPreset,
|
|
installed: true,
|
|
configured: true,
|
|
available: true,
|
|
status: "configured",
|
|
connection_summary: "https://app.xmind.com/api/mcp",
|
|
}
|
|
: xmindMcpPreset],
|
|
installed_count: connected ? 1 : 0,
|
|
});
|
|
}
|
|
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-123") {
|
|
connected = true;
|
|
return jsonResponse({
|
|
flow_id: "flow-123",
|
|
name: "xmind",
|
|
status: "connected",
|
|
expires_in: 295,
|
|
hot_reload: {
|
|
ok: false,
|
|
requires_restart: false,
|
|
connected: ["xmind"],
|
|
failed: ["notion"],
|
|
message: "MCP config reloaded, but some servers did not connect: notion",
|
|
},
|
|
});
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
requestMutationMock.mockImplementation(async (action: string) => {
|
|
if (action === "settings.mcp.oauth_start") {
|
|
return {
|
|
flow_id: "flow-123",
|
|
name: "xmind",
|
|
status: "authorization_required",
|
|
expires_in: 300,
|
|
authorization_url: "https://accounts.xmind.test/authorize?state=state-123",
|
|
};
|
|
}
|
|
return settingsPayload();
|
|
});
|
|
const replace = vi.fn();
|
|
const popup = {
|
|
opener: window,
|
|
closed: false,
|
|
location: { replace },
|
|
document: { title: "", body: { textContent: "" } },
|
|
focus: vi.fn(),
|
|
close: vi.fn(),
|
|
};
|
|
const open = vi.fn(() => popup);
|
|
vi.stubGlobal("open", open);
|
|
|
|
renderSettingsView({ initialSection: "apps" });
|
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
|
expect(screen.getByText("MCP tools")).toBeInTheDocument();
|
|
const connectButton = await screen.findByRole("button", { name: "Connect Xmind" });
|
|
expect(connectButton).toHaveTextContent("Connect");
|
|
fireEvent.click(connectButton);
|
|
|
|
expect(open).toHaveBeenCalledWith(
|
|
"about:blank",
|
|
"nanobot-mcp-oauth",
|
|
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
|
|
);
|
|
await waitFor(() => expect(replace).toHaveBeenCalledWith(
|
|
"https://accounts.xmind.test/authorize?state=state-123",
|
|
));
|
|
expect(popup.opener).toBeNull();
|
|
expect(screen.queryByRole("textbox", { name: /authorization/i })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
expect(screen.getByRole("status")).toHaveTextContent(
|
|
"Finish signing in in the browser window.",
|
|
);
|
|
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toHaveTextContent(
|
|
"Connecting…",
|
|
);
|
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
|
|
|
expect(await screen.findByRole("button", { name: "Manage Xmind" }, { timeout: 2500 }))
|
|
.toHaveTextContent("Manage");
|
|
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Xmind connected.")).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/some servers did not connect: notion/i)).not.toBeInTheDocument();
|
|
expect(popup.close).toHaveBeenCalledTimes(1);
|
|
expect(replace).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"/api/settings/mcp-oauth/status?flow_id=flow-123",
|
|
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
|
);
|
|
});
|
|
|
|
it("shows a real OAuth runtime failure and restarts authorization without a success check", async () => {
|
|
const failedPreset = {
|
|
...xmindMcpPreset,
|
|
installed: true,
|
|
configured: true,
|
|
available: true,
|
|
status: "configured",
|
|
runtime_status: "failed",
|
|
connection_summary: "https://app.xmind.com/api/mcp",
|
|
};
|
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
|
if (url === "/api/settings/cli-apps") {
|
|
return jsonResponse({ apps: [], installed_count: 0 });
|
|
}
|
|
if (url === "/api/settings/mcp-presets") {
|
|
return jsonResponse({ presets: [failedPreset], installed_count: 1 });
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
vi.stubGlobal("open", vi.fn(() => null));
|
|
requestMutationMock.mockRejectedValueOnce(new Error("Stopped after request assertion"));
|
|
|
|
renderSettingsView({ initialSection: "apps" });
|
|
fireEvent.click(await screen.findByRole("button", { name: "Ready" }));
|
|
expect(await screen.findByText("No tools are ready yet.")).toBeInTheDocument();
|
|
expect(screen.queryByRole("heading", { name: "Xmind" })).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
|
|
|
const heading = await screen.findByRole("heading", { name: "Xmind" });
|
|
const row = heading.closest("article");
|
|
expect(row).not.toBeNull();
|
|
expect(row?.parentElement).toHaveClass("xl:grid-cols-2");
|
|
expect(within(row as HTMLElement).queryByText("MCP")).not.toBeInTheDocument();
|
|
const failed = within(row as HTMLElement).getByText("Connection failed.");
|
|
expect(failed.closest("button")).toBeNull();
|
|
expect(failed.closest("p")?.querySelector(".lucide-triangle-alert")).not.toBeNull();
|
|
expect(row?.querySelector(".lucide-check")).toBeNull();
|
|
expect(within(row as HTMLElement).getByRole("button", { name: "Manage Xmind" })).toHaveTextContent(
|
|
"Fix connection",
|
|
);
|
|
|
|
await act(() => i18n.changeLanguage("zh-CN"));
|
|
expect(within(row as HTMLElement).getByText("连接失败。")).toBeInTheDocument();
|
|
const manage = within(row as HTMLElement).getByRole("button", { name: "管理 Xmind" });
|
|
expect(manage).toHaveTextContent("修复连接");
|
|
fireEvent.click(manage);
|
|
const dialog = screen.getByRole("dialog", { name: "Xmind" });
|
|
expect(within(dialog).getByRole("tab", { name: "连接" })).toHaveAttribute("aria-selected", "true");
|
|
expect(within(dialog).getByText("创建、读取和编辑 Xmind 云端思维导图。")).toHaveClass("sr-only");
|
|
expect(within(dialog).getByText("连接失败", { exact: true })).toBeInTheDocument();
|
|
expect(within(dialog).getByRole("button", { name: "移除连接" })).toBeInTheDocument();
|
|
const reconnect = within(dialog).getByRole("button", { name: "重新连接" });
|
|
fireEvent.click(reconnect);
|
|
|
|
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
|
"settings.mcp.oauth_start",
|
|
{ name: "xmind", reset: true },
|
|
30_000,
|
|
));
|
|
await act(() => i18n.changeLanguage("en"));
|
|
});
|
|
|
|
it("refreshes a connecting MCP snapshot until the runtime attempt settles", async () => {
|
|
const connectingPreset = {
|
|
...xmindMcpPreset,
|
|
installed: true,
|
|
configured: true,
|
|
available: true,
|
|
status: "configured",
|
|
runtime_status: "connecting",
|
|
connection_summary: "https://app.xmind.com/api/mcp",
|
|
};
|
|
let mcpPresetRequests = 0;
|
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
|
if (url === "/api/settings/cli-apps") {
|
|
return jsonResponse({ apps: [], installed_count: 0 });
|
|
}
|
|
if (url === "/api/settings/mcp-presets") {
|
|
mcpPresetRequests += 1;
|
|
return jsonResponse({
|
|
presets: [{
|
|
...connectingPreset,
|
|
runtime_status: mcpPresetRequests === 1 ? "connecting" : "connected",
|
|
}],
|
|
installed_count: 1,
|
|
});
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
renderSettingsView({ initialSection: "apps" });
|
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
|
|
|
expect(await screen.findByRole("button", { name: "Xmind: Connecting…" }))
|
|
.toHaveTextContent("Connecting…");
|
|
expect(await screen.findByRole(
|
|
"button",
|
|
{ name: "Manage Xmind" },
|
|
{ timeout: 2_500 },
|
|
)).toHaveTextContent("Manage");
|
|
expect(mcpPresetRequests).toBe(2);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Ready" }));
|
|
expect(await screen.findByRole("heading", { name: "Xmind" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("retries a failed custom MCP and only shows a success check after it connects", async () => {
|
|
const failedCustom = {
|
|
...xmindMcpPreset,
|
|
name: "team-docs",
|
|
display_name: "team-docs",
|
|
auth: null,
|
|
source: "custom",
|
|
installed: true,
|
|
configured: true,
|
|
available: true,
|
|
status: "configured",
|
|
runtime_status: "failed",
|
|
connection_summary: "https://mcp.example.com/mcp",
|
|
};
|
|
const connectedCustom = { ...failedCustom, runtime_status: "connected" };
|
|
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: [failedCustom], installed_count: 1 });
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
requestMutationMock.mockResolvedValueOnce({
|
|
presets: [connectedCustom],
|
|
installed_count: 1,
|
|
requires_restart: false,
|
|
hot_reload: {
|
|
ok: true,
|
|
message: "MCP connections refreshed without restarting nanobot.",
|
|
connected: ["team-docs"],
|
|
failed: [],
|
|
},
|
|
last_action: { ok: true, message: "Retried connection for MCP server team-docs." },
|
|
});
|
|
|
|
renderSettingsView({ initialSection: "apps" });
|
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
|
expect(await screen.findByText("Connection failed.")).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Manage team-docs" }));
|
|
fireEvent.click(within(screen.getByRole("dialog", { name: "team-docs" }))
|
|
.getByRole("button", { name: "Reconnect" }));
|
|
|
|
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
|
"settings.mcp.reconnect",
|
|
{ name: "team-docs" },
|
|
20_000,
|
|
));
|
|
const connected = await screen.findByRole("button", { name: "Manage team-docs" });
|
|
expect(connected).toHaveTextContent("Manage");
|
|
expect(connected.querySelector(".lucide-check")).not.toBeNull();
|
|
fireEvent.click(screen.getByRole("button", { name: "Ready" }));
|
|
const readyHeading = await screen.findByRole("heading", { name: "team-docs" });
|
|
expect(within(readyHeading.closest("article") as HTMLElement).getByText("MCP"))
|
|
.toBeInTheDocument();
|
|
});
|
|
|
|
it("configures OAuth for a custom remote MCP without importing JSON", async () => {
|
|
const customPreset = {
|
|
...xmindMcpPreset,
|
|
name: "team-mcp",
|
|
display_name: "team-mcp",
|
|
source: "custom",
|
|
installed: true,
|
|
status: "authorization_required",
|
|
connection_summary: "https://mcp.example.com/mcp",
|
|
};
|
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
|
if (url === "/api/settings/cli-apps") {
|
|
return jsonResponse({ apps: [], installed_count: 0 });
|
|
}
|
|
if (url === "/api/settings/mcp-presets") {
|
|
return jsonResponse({ presets: [], installed_count: 0 });
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
requestMutationMock.mockImplementation(async (action: string) => {
|
|
if (action === "settings.mcp.custom") {
|
|
return {
|
|
presets: [customPreset],
|
|
installed_count: 1,
|
|
hot_reload: {
|
|
ok: false,
|
|
message: "MCP config reloaded, but some servers did not connect: team-mcp",
|
|
failed: ["team-mcp"],
|
|
},
|
|
last_action: { ok: true, message: "Saved custom MCP server team-mcp." },
|
|
};
|
|
}
|
|
return settingsPayload();
|
|
});
|
|
|
|
renderSettingsView({ initialSection: "apps" });
|
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
|
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
|
|
|
|
expect(screen.queryByText("Authentication")).not.toBeInTheDocument();
|
|
fireEvent.change(screen.getByLabelText("Server name"), {
|
|
target: { value: "team-mcp" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "HTTP" }));
|
|
fireEvent.change(screen.getByLabelText("URL"), {
|
|
target: { value: "https://mcp.example.com/mcp" },
|
|
});
|
|
|
|
const authentication = screen.getByRole("group", { name: "Authentication" });
|
|
const oauth = within(authentication).getByRole("button", { name: "OAuth" });
|
|
expect(oauth).toHaveAttribute("aria-pressed", "false");
|
|
|
|
fireEvent.click(within(authentication).getByRole("button", { name: "Headers" }));
|
|
fireEvent.change(screen.getByLabelText("Headers JSON"), {
|
|
target: { value: '{"Authorization":"Bearer stale"}' },
|
|
});
|
|
expect(screen.getByText("Add the request headers used by this server.")).toBeInTheDocument();
|
|
|
|
fireEvent.click(oauth);
|
|
expect(oauth).toHaveAttribute("aria-pressed", "true");
|
|
expect(screen.queryByLabelText("Headers JSON")).not.toBeInTheDocument();
|
|
expect(
|
|
screen.getByText("Save the server, then select Connect to sign in."),
|
|
).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save MCP" }));
|
|
|
|
await waitFor(() => {
|
|
const saveCall = requestMutationMock.mock.calls.find(
|
|
([action]) => action === "settings.mcp.custom",
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
const values = saveCall?.[1] as Record<string, string>;
|
|
expect(values).toMatchObject({
|
|
name: "team-mcp",
|
|
transport: "streamableHttp",
|
|
url: "https://mcp.example.com/mcp",
|
|
auth: "oauth",
|
|
});
|
|
expect(values).not.toHaveProperty("headers");
|
|
expect(saveCall?.[2]).toBe(20_000);
|
|
});
|
|
expect(await screen.findByRole("button", { name: "Connect team-mcp" }))
|
|
.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByText("MCP config reloaded, but some servers did not connect: team-mcp"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("offers a pasted callback flow when the remote WebUI uses HTTP", async () => {
|
|
let completed = false;
|
|
const callbackUrl =
|
|
"http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=manual-state";
|
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
|
if (url === "/api/settings/cli-apps") {
|
|
return jsonResponse({ apps: [], installed_count: 0 });
|
|
}
|
|
if (url === "/api/settings/mcp-presets") {
|
|
return jsonResponse({
|
|
presets: [completed
|
|
? {
|
|
...xmindMcpPreset,
|
|
installed: true,
|
|
configured: true,
|
|
available: true,
|
|
status: "configured",
|
|
connection_summary: "https://app.xmind.com/api/mcp",
|
|
}
|
|
: xmindMcpPreset],
|
|
installed_count: completed ? 1 : 0,
|
|
});
|
|
}
|
|
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-manual") {
|
|
return jsonResponse({
|
|
flow_id: "flow-manual",
|
|
name: "xmind",
|
|
status: completed ? "connected" : "authorization_required",
|
|
expires_in: 298,
|
|
completion_input: "callback_url",
|
|
authorization_url: completed
|
|
? undefined
|
|
: "https://accounts.xmind.test/authorize?state=manual-state",
|
|
hot_reload: completed ? { ok: true, requires_restart: false } : undefined,
|
|
});
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
requestMutationMock.mockImplementation(async (action: string) => {
|
|
if (action === "settings.mcp.oauth_start") {
|
|
return {
|
|
flow_id: "flow-manual",
|
|
name: "xmind",
|
|
status: "authorization_required",
|
|
expires_in: 300,
|
|
completion_input: "callback_url",
|
|
authorization_url: "https://accounts.xmind.test/authorize?state=manual-state",
|
|
};
|
|
}
|
|
if (action === "settings.mcp.oauth_complete") {
|
|
completed = true;
|
|
return {
|
|
flow_id: "flow-manual",
|
|
name: "xmind",
|
|
status: "connecting",
|
|
expires_in: 299,
|
|
completion_input: "callback_url",
|
|
};
|
|
}
|
|
return settingsPayload();
|
|
});
|
|
const popup = {
|
|
opener: window,
|
|
closed: false,
|
|
location: { replace: vi.fn() },
|
|
document: { title: "", body: { textContent: "" } },
|
|
focus: vi.fn(),
|
|
close: vi.fn(),
|
|
};
|
|
vi.stubGlobal("open", vi.fn(() => popup));
|
|
|
|
renderSettingsView({ initialSection: "apps" });
|
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
|
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
|
|
|
const callbackInput = await screen.findByRole("textbox", { name: "Full callback URL" });
|
|
expect(screen.getByText(/localhost page will not load/i)).toBeInTheDocument();
|
|
expect(screen.getByRole("status")).toHaveTextContent(
|
|
"Finish signing in, then paste the callback URL into nanobot.",
|
|
);
|
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
|
|
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" }));
|
|
|
|
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
|
"settings.mcp.oauth_complete",
|
|
{ flow_id: "flow-manual", callback_url: callbackUrl },
|
|
20_000,
|
|
));
|
|
expect(await screen.findByRole("button", { name: "Manage Xmind" }, { timeout: 2500 }))
|
|
.toHaveTextContent("Manage");
|
|
expect(screen.queryByRole("textbox", { name: "Full callback URL" })).not.toBeInTheDocument();
|
|
expect(popup.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("lets the user cancel an active OAuth connection after closing the popup", async () => {
|
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
|
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
|
if (url === "/api/settings/mcp-presets") {
|
|
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
|
|
}
|
|
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-cancel") {
|
|
return new Promise<Response>(() => {});
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
requestMutationMock.mockImplementation(async (action: string) => {
|
|
if (action === "settings.mcp.oauth_start") {
|
|
return {
|
|
flow_id: "flow-cancel",
|
|
name: "xmind",
|
|
status: "authorization_required",
|
|
expires_in: 300,
|
|
authorization_url: "https://accounts.xmind.test/authorize?state=cancel",
|
|
};
|
|
}
|
|
if (action === "settings.mcp.oauth_cancel") {
|
|
return {
|
|
flow_id: "flow-cancel",
|
|
name: "xmind",
|
|
status: "cancelled",
|
|
expires_in: 299,
|
|
};
|
|
}
|
|
return settingsPayload();
|
|
});
|
|
const popup = {
|
|
opener: window,
|
|
closed: false,
|
|
location: { replace: vi.fn() },
|
|
document: { title: "", body: { textContent: "" } },
|
|
focus: vi.fn(),
|
|
close: vi.fn(),
|
|
};
|
|
vi.stubGlobal("open", vi.fn(() => popup));
|
|
|
|
renderSettingsView({ initialSection: "apps" });
|
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
|
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
|
|
|
const cancelButton = await screen.findByRole("button", { name: "Cancel" });
|
|
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toBeInTheDocument();
|
|
popup.closed = true;
|
|
fireEvent.click(cancelButton);
|
|
|
|
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
|
"settings.mcp.oauth_cancel",
|
|
{ flow_id: "flow-cancel" },
|
|
20_000,
|
|
));
|
|
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Connecting Xmind" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
|
|
expect(popup.close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("silently removes an MCP when the card already shows the result", async () => {
|
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
|
if (url === "/api/settings/cli-apps") {
|
|
return jsonResponse({ apps: [], installed_count: 0 });
|
|
}
|
|
if (url === "/api/settings/mcp-presets") {
|
|
return jsonResponse({
|
|
presets: [{
|
|
...xmindMcpPreset,
|
|
installed: true,
|
|
configured: true,
|
|
available: true,
|
|
status: "configured",
|
|
connection_summary: "https://app.xmind.com/api/mcp",
|
|
}],
|
|
installed_count: 1,
|
|
});
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
requestMutationMock.mockResolvedValueOnce({
|
|
presets: [xmindMcpPreset],
|
|
installed_count: 0,
|
|
requires_restart: false,
|
|
hot_reload: {
|
|
ok: true,
|
|
message: "MCP config reloaded without restarting nanobot.",
|
|
},
|
|
last_action: {
|
|
ok: true,
|
|
message: "Removed MCP preset for Xmind. MCP config reloaded without restarting nanobot.",
|
|
removed: true,
|
|
},
|
|
});
|
|
|
|
renderSettingsView({ initialSection: "apps" });
|
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
|
fireEvent.click(await screen.findByRole("button", { name: "Manage Xmind" }));
|
|
const dialog = screen.getByRole("dialog", { name: "Xmind" });
|
|
fireEvent.click(within(dialog).getByRole("tab", { name: "Connection" }));
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Remove connection" }));
|
|
|
|
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
|
"settings.mcp.remove",
|
|
{ name: "xmind" },
|
|
20_000,
|
|
));
|
|
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
|
|
expect(screen.queryByText(/Removed MCP preset|reloaded without restarting/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("offers a one-click recovery when the OAuth popup is blocked", async () => {
|
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
|
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
|
if (url === "/api/settings/mcp-presets") {
|
|
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
|
|
}
|
|
return new Promise<Response>(() => {});
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
requestMutationMock.mockImplementation(async (action: string) => {
|
|
if (action === "settings.mcp.oauth_start") {
|
|
return {
|
|
flow_id: "flow-blocked",
|
|
name: "xmind",
|
|
status: "authorization_required",
|
|
expires_in: 300,
|
|
authorization_url: "https://accounts.xmind.test/authorize?state=blocked",
|
|
};
|
|
}
|
|
return settingsPayload();
|
|
});
|
|
const popup = {
|
|
opener: window,
|
|
closed: false,
|
|
location: { replace: vi.fn() },
|
|
focus: vi.fn(),
|
|
close: vi.fn(),
|
|
};
|
|
const open = vi.fn()
|
|
.mockReturnValueOnce(null)
|
|
.mockReturnValueOnce(popup);
|
|
vi.stubGlobal("open", open);
|
|
|
|
renderSettingsView({ initialSection: "apps" });
|
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
|
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue sign-in" });
|
|
expect(screen.getByRole("status")).toHaveTextContent("Open the sign-in page to continue.");
|
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
|
fireEvent.click(continueButton);
|
|
expect(open).toHaveBeenLastCalledWith(
|
|
"https://accounts.xmind.test/authorize?state=blocked",
|
|
"nanobot-mcp-oauth",
|
|
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
|
|
);
|
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not mistake a COOP-isolated OAuth tab for a blocked popup", async () => {
|
|
let popupIsolated = false;
|
|
let statusCalls = 0;
|
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
|
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
|
if (url === "/api/settings/mcp-presets") {
|
|
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
|
|
}
|
|
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-coop") {
|
|
statusCalls += 1;
|
|
return jsonResponse({
|
|
flow_id: "flow-coop",
|
|
name: "xmind",
|
|
status: statusCalls === 1 ? "authorization_required" : "failed",
|
|
expires_in: 299,
|
|
error: statusCalls === 1 ? undefined : "Cancelled for test cleanup.",
|
|
authorization_url: statusCalls === 1
|
|
? "https://accounts.xmind.test/authorize?state=coop"
|
|
: undefined,
|
|
});
|
|
}
|
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
requestMutationMock.mockImplementation(async (action: string) => {
|
|
if (action === "settings.mcp.oauth_start") {
|
|
return {
|
|
flow_id: "flow-coop",
|
|
name: "xmind",
|
|
status: "authorization_required",
|
|
expires_in: 300,
|
|
authorization_url: "https://accounts.xmind.test/authorize?state=coop",
|
|
};
|
|
}
|
|
return settingsPayload();
|
|
});
|
|
const popup = {
|
|
opener: window,
|
|
get closed() {
|
|
return popupIsolated;
|
|
},
|
|
location: {
|
|
replace: vi.fn(() => {
|
|
popupIsolated = true;
|
|
}),
|
|
},
|
|
document: { title: "", body: { textContent: "" } },
|
|
focus: vi.fn(),
|
|
close: vi.fn(),
|
|
};
|
|
vi.stubGlobal("open", vi.fn(() => popup));
|
|
|
|
renderSettingsView({ initialSection: "apps" });
|
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
|
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
|
|
|
await waitFor(() => expect(statusCalls).toBe(1), { timeout: 2000 });
|
|
expect(screen.getByRole("status")).toHaveTextContent(
|
|
"Finish signing in in the browser window.",
|
|
);
|
|
expect(screen.queryByRole("button", { name: "Continue sign-in" })).not.toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
|
});
|
|
|
|
});
|