From 5fa0c5a56dae4efc859bf14ade9472963e3be30e Mon Sep 17 00:00:00 2001 From: Conastin Date: Fri, 11 Sep 2026 16:48:46 +0800 Subject: [PATCH] fix(mcp): connect before listing databases in scope picker --- .../settings/McpDatabaseScopePicker.vue | 5 + .../__tests__/McpDatabaseScopePicker.spec.ts | 185 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 apps/desktop/src/components/settings/__tests__/McpDatabaseScopePicker.spec.ts diff --git a/apps/desktop/src/components/settings/McpDatabaseScopePicker.vue b/apps/desktop/src/components/settings/McpDatabaseScopePicker.vue index 6e9e560d14..eb01ec8970 100644 --- a/apps/desktop/src/components/settings/McpDatabaseScopePicker.vue +++ b/apps/desktop/src/components/settings/McpDatabaseScopePicker.vue @@ -6,12 +6,14 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { listDatabases, mongoListDatabases, redisListDatabases } from "@/lib/backend/api"; +import { useConnectionStore } from "@/stores/connectionStore"; import type { McpConnectionPolicy } from "@/stores/settingsStore"; import type { ConnectionConfig } from "@/types/database"; type DatabaseScope = McpConnectionPolicy["databaseScope"]; type ExecutionMode = "read_only" | "safe_write" | "high_risk_write"; const { t } = useI18n(); +const connectionStore = useConnectionStore(); const PAGE_SIZE_OPTIONS = [6, 10, 20] as const; const DATABASE_SCOPES: DatabaseScope[] = ["all", "selected", "none"]; @@ -167,6 +169,9 @@ async function loadConnectionDatabases() { loadingConnectionId.value = connection.id; loadError.value = ""; try { + // SQL 连接的 list_databases 只读后端现有连接池,从未在侧边栏连接过的连接 + // 会报 "Connection not found";与侧边栏流程一致,先确保连接建立。 + await connectionStore.ensureConnected(connection.id, { activate: false }); const databases = connection.db_type === "mongodb" ? await mongoListDatabases(connection.id) : connection.db_type === "redis" ? (await redisListDatabases(connection.id)).map((database) => String(database.db)) : (await listDatabases(connection.id)).map((database) => database.name); databasesByConnection.value = { ...databasesByConnection.value, diff --git a/apps/desktop/src/components/settings/__tests__/McpDatabaseScopePicker.spec.ts b/apps/desktop/src/components/settings/__tests__/McpDatabaseScopePicker.spec.ts new file mode 100644 index 0000000000..0816c07c5a --- /dev/null +++ b/apps/desktop/src/components/settings/__tests__/McpDatabaseScopePicker.spec.ts @@ -0,0 +1,185 @@ +// @vitest-environment happy-dom +// MCP 数据库范围选择器回归测试: +// 加载资源列表前先 ensureConnected(修复未连接的 SQL 连接报 Connection not found)。 + +import { createApp, nextTick, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + listDatabases: vi.fn(), + mongoListDatabases: vi.fn(), + redisListDatabases: vi.fn(), + ensureConnected: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string, params?: Record) => (params?.error === undefined ? key : `${key}:${String(params.error)}`) }) })); +vi.mock("@/lib/backend/api", () => ({ + listDatabases: mocks.listDatabases, + mongoListDatabases: mocks.mongoListDatabases, + redisListDatabases: mocks.redisListDatabases, +})); +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected }), +})); +vi.mock("@lucide/vue", async () => { + const { defineComponent, h } = await import("vue"); + const Icon = defineComponent({ setup: () => () => h("i") }); + return { Check: Icon, ChevronLeft: Icon, ChevronRight: Icon, Loader2: Icon, Plus: Icon, RefreshCw: Icon, Search: Icon }; +}); + +async function passthroughComponent() { + const { defineComponent, h } = await import("vue"); + return defineComponent({ + inheritAttrs: false, + setup: + (_, { slots, attrs }) => + () => + h("div", { ...attrs }, slots.default?.()), + }); +} +vi.mock("@/components/ui/button", async () => ({ Button: await passthroughComponent() })); +vi.mock("@/components/ui/badge", async () => ({ Badge: await passthroughComponent() })); +vi.mock("@/components/ui/input", async () => { + const { defineComponent, h } = await import("vue"); + const Input = defineComponent({ + props: ["modelValue"], + emits: ["update:modelValue"], + setup: + (props, { emit, attrs }) => + () => + h("input", { + ...attrs, + value: props.modelValue, + onInput: (e: Event) => emit("update:modelValue", (e.target as HTMLInputElement).value), + onKeydown: (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + attrs.onKeydownEnter?.(e); + } + }, + }), + }); + return { Input }; +}); + +import McpDatabaseScopePicker from "@/components/settings/McpDatabaseScopePicker.vue"; +import type { McpConnectionPolicy } from "@/stores/settingsStore"; +import type { ConnectionConfig } from "@/types/database"; + +function fakeConnection(id: string): ConnectionConfig { + return { id, name: `conn-${id}`, db_type: "oracle" } as unknown as ConnectionConfig; +} + +function emptyPolicy(connectionId: string, scope: McpConnectionPolicy["databaseScope"] = "all"): McpConnectionPolicy { + return { + connectionId, + readOnly: false, + allowDangerousSql: false, + executionModeConfigured: false, + executionModePolicyVersion: null, + databaseScope: scope, + allowedDatabases: [], + databasePolicies: [], + }; +} + +function mountHarness(policies: McpConnectionPolicy[], disabled = false) { + const root = document.createElement("div"); + document.body.appendChild(root); + const emitted = { policies: [] as McpConnectionPolicy[][] }; + const app = createApp(McpDatabaseScopePicker, { + connections: [fakeConnection("oracle-1")], + allowedConnectionIds: null, + connectionPolicies: policies, + disabled, + busy: false, + "onUpdate:connectionPolicies": (next: McpConnectionPolicy[]) => emitted.policies.push(next), + }); + app.mount(root); + harness = { app, root }; + return { app, emitted, root }; +} + +let harness: { app: App; root: HTMLElement } | null = null; + +beforeEach(() => { + mocks.ensureConnected.mockReset().mockResolvedValue(undefined); + mocks.listDatabases.mockReset().mockResolvedValue([{ name: "LOADED_DB" }]); +}); +afterEach(() => { + harness?.app?.unmount(); + harness = null; + document.body.innerHTML = ""; +}); + +function clickScopeRadio(root: HTMLElement, scope: string) { + const radio = Array.from(root.querySelectorAll("[data-database-scope]")).find((el) => el.getAttribute("data-database-scope") === scope); + if (!radio) throw new Error(`scope radio ${scope} not found`); + radio.dispatchEvent(new MouseEvent("click")); +} + +function manualInput(root: HTMLElement): HTMLInputElement { + const input = Array.from(root.querySelectorAll("input")).find((i) => i.placeholder === "settings.mcpDatabaseManualPlaceholder"); + if (!input) throw new Error("manual input not found"); + return input as HTMLInputElement; +} + +function loadButton(root: HTMLElement): HTMLElement { + const candidates = Array.from(root.querySelectorAll("div,button")).filter((el) => el.textContent?.includes("settings.mcpDatabaseLoadButton")); + const deepest = candidates.at(-1); + if (!deepest) throw new Error("load button not found"); + return deepest as HTMLElement; +} + +function addManualDatabase(root: HTMLElement, name: string) { + const input = manualInput(root); + input.value = name; + input.dispatchEvent(new Event("input")); + return nextTick().then(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + return nextTick(); + }); +} + +describe("McpDatabaseScopePicker", () => { + it("加载资源列表前先 ensureConnected(不激活侧边栏),未连接的 SQL 连接不再直接调 listDatabases", async () => { + mocks.listDatabases.mockResolvedValue([{ name: "ORCL" }]); + const first = mountHarness([]); + await nextTick(); + clickScopeRadio(first.root, "selected"); + await nextTick(); + first.app.unmount(); + harness = null; + + const second = mountHarness([emptyPolicy("oracle-1", "selected")]); + await nextTick(); + loadButton(second.root).click(); + await new Promise((r) => setTimeout(r, 10)); + await nextTick(); + + expect(mocks.ensureConnected).toHaveBeenCalledWith("oracle-1", { activate: false }); + // ensureConnected 成功后加载数据库列表 + expect(mocks.listDatabases).toHaveBeenCalledWith("oracle-1"); + expect(second.root.textContent).toContain("ORCL"); + }); + + it("加载失败时 ensureConnected 的连接错误透传到错误提示", async () => { + mocks.ensureConnected.mockRejectedValue(new Error("ORA-12170: 连接超时")); + const first = mountHarness([]); + await nextTick(); + clickScopeRadio(first.root, "selected"); + await nextTick(); + first.app.unmount(); + harness = null; + + const second = mountHarness([emptyPolicy("oracle-1", "selected")]); + await nextTick(); + loadButton(second.root).click(); + await new Promise((r) => setTimeout(r, 10)); + await nextTick(); + + expect(second.root.textContent).toContain("settings.mcpDatabaseLoadFailed"); + expect(second.root.textContent).toContain("ORA-12170"); + expect(mocks.listDatabases).not.toHaveBeenCalled(); + }); +});