From a9b44709440def53e682ff9016d7528bd161049d Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Thu, 10 Sep 2026 18:04:05 +0800 Subject: [PATCH] fix(mcp): persist query timeout edits reliably --- .../editor/EditorSettingsDialog.vue | 149 ++++++++--- ...Dialog.mcpQueryTimeout.integration.spec.ts | 123 ++++++++++ ...itorSettingsDialog.mcpQueryTimeout.spec.ts | 231 +++++++++++++++++- .../__tests__/mcpQueryTimeoutHarness.ts | 43 ++++ apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/ko.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + 12 files changed, 525 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.mcpQueryTimeout.integration.spec.ts create mode 100644 apps/desktop/src/components/editor/__tests__/mcpQueryTimeoutHarness.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 6288f6f6fa..c7f56eb3e3 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -367,6 +367,9 @@ const settingsTitleComponent = computed(() => (isSettingsPage.value ? "h2" : Dia const showUnsavedSettingsCloseConfirm = ref(false); function requestCloseSettings(nextOpen: boolean) { + // Flush any pending debounced MCP query-timeout save so a value typed right + // before closing is persisted instead of dropped (see onMcpQueryTimeoutInput). + flushMcpQueryTimeoutSave(); if (shouldConfirmEditorSettingsDialogClose(nextOpen, hasChanges())) { showUnsavedSettingsCloseConfirm.value = true; return; @@ -2586,21 +2589,85 @@ watch( }, ); -function onMcpQueryTimeoutInput() { - const raw = mcpQueryTimeoutInput.value.trim(); +type McpQueryTimeoutSaveStatus = "idle" | "saving" | "saved" | "failed"; +const mcpQueryTimeoutSaveStatus = ref("idle"); +let mcpQueryTimeoutSavedStatusTimer: ReturnType | null = null; + +function setMcpQueryTimeoutSaveStatus(status: McpQueryTimeoutSaveStatus) { + if (mcpQueryTimeoutSavedStatusTimer !== null) { + clearTimeout(mcpQueryTimeoutSavedStatusTimer); + mcpQueryTimeoutSavedStatusTimer = null; + } + mcpQueryTimeoutSaveStatus.value = status; + if (status === "saved") { + mcpQueryTimeoutSavedStatusTimer = setTimeout(() => { + mcpQueryTimeoutSavedStatusTimer = null; + mcpQueryTimeoutSaveStatus.value = "idle"; + }, 1600); + } +} + +// Debounce the persist so rapid typing coalesces into a single SQLite write. +// `flushMcpQueryTimeoutSave` runs on the settings-close path so a value typed +// right before closing is still persisted (the legacy @change binding only +// fired on blur/Enter, silently dropping the value when the window closed). +const MCP_QUERY_TIMEOUT_SAVE_DEBOUNCE_MS = 300; +let mcpQueryTimeoutSaveTimer: ReturnType | null = null; +let mcpQueryTimeoutPendingValue: number | null | undefined; + +function onMcpQueryTimeoutInput(event: Event) { + // Read the value from the native input (not the ref). This handler runs in + // capture phase, before Input's passive v-model proxy updates the ref. + const target = event.currentTarget as HTMLInputElement; + // Number inputs expose incomplete/invalid edits as an empty value. Do not + // mistake that browser state for an explicit request to inherit the timeout. + if (target.validity.badInput) return; + const raw = target.value.trim(); + setMcpQueryTimeoutSaveStatus("saving"); if (raw === "") { - void saveMcpPolicy({ queryTimeoutSecs: null }); - return; + mcpQueryTimeoutPendingValue = null; + } else { + const parsed = Number(raw); + // Backend stores the value as u64; keep out-of-range integers on the + // invalid-value path instead of failing serde later with a generic error. + if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed) || parsed > Number("18446744073709551615")) { + toast(t("settings.mcpQueryTimeoutInvalid"), 5000); + // Revert before Input's bubble-phase v-model handler sees the value, so + // the proxy and parent ref remain aligned. + const reverted = settingsStore.mcpGlobalPolicy.queryTimeoutSecs === null ? "" : String(settingsStore.mcpGlobalPolicy.queryTimeoutSecs); + mcpQueryTimeoutInput.value = reverted; + target.value = reverted; + mcpQueryTimeoutPendingValue = undefined; + setMcpQueryTimeoutSaveStatus("idle"); + return; + } + mcpQueryTimeoutPendingValue = parsed; } - const parsed = Number(raw); - // Backend stores the value as u64; keep out-of-range integers on the - // invalid-value path instead of failing serde later with a generic error. - if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed) || parsed > Number("18446744073709551615")) { - toast(t("settings.mcpQueryTimeoutInvalid"), 5000); - mcpQueryTimeoutInput.value = settingsStore.mcpGlobalPolicy.queryTimeoutSecs === null ? "" : String(settingsStore.mcpGlobalPolicy.queryTimeoutSecs); - return; + if (mcpQueryTimeoutSaveTimer !== null) clearTimeout(mcpQueryTimeoutSaveTimer); + mcpQueryTimeoutSaveTimer = setTimeout(() => { + mcpQueryTimeoutSaveTimer = null; + flushMcpQueryTimeoutSave(); + }, MCP_QUERY_TIMEOUT_SAVE_DEBOUNCE_MS); +} + +function flushMcpQueryTimeoutSave() { + if (mcpQueryTimeoutSaveTimer !== null) { + clearTimeout(mcpQueryTimeoutSaveTimer); + mcpQueryTimeoutSaveTimer = null; } - void saveMcpPolicy({ queryTimeoutSecs: parsed }); + if (mcpQueryTimeoutPendingValue === undefined) return; + // Another MCP policy mutation may be in flight. Retain the value until the + // shared mutation gate reopens; saveMcpPolicy's finally block retries it. + if (mcpPolicyControlsDisabled.value) return; + const value = mcpQueryTimeoutPendingValue; + mcpQueryTimeoutPendingValue = undefined; + void saveMcpPolicy( + { queryTimeoutSecs: value }, + { + onSuccess: () => setMcpQueryTimeoutSaveStatus("saved"), + onFailure: () => setMcpQueryTimeoutSaveStatus("failed"), + }, + ); } const mcpSelectableConnections = computed(() => connectionStore.connections); const mcpGroupRows = computed(() => connectionGroupDestinationRows(connectionStore.sidebarLayout)); @@ -2655,33 +2722,41 @@ const mcpHttpHasUnsavedChanges = computed(() => { const webMcpEndpoint = computed(() => (webMcpHttpStatus.value ? `${window.location.origin}${webMcpHttpStatus.value.endpointPath}` : "")); -async function saveMcpPolicy(partial: { - readOnly?: boolean; - allowDangerousSql?: boolean; - allowedConnectionIds?: string[] | null; - allowedGroupIds?: string[]; - allowedToolNames?: string[] | null; - connectionPolicies?: { - connectionId: string; - readOnly: boolean; - allowDangerousSql: boolean; - executionModeConfigured: boolean; - executionModePolicyVersion: number | null; - databaseScope: "all" | "selected" | "none"; - allowedDatabases: string[]; - databasePolicies: { databaseName: string; readOnly: boolean; allowDangerousSql: boolean }[]; - }[]; - groupPolicies?: McpGroupPolicy[]; - queryTimeoutSecs?: number | null; -}) { +async function saveMcpPolicy( + partial: { + readOnly?: boolean; + allowDangerousSql?: boolean; + allowedConnectionIds?: string[] | null; + allowedGroupIds?: string[]; + allowedToolNames?: string[] | null; + connectionPolicies?: { + connectionId: string; + readOnly: boolean; + allowDangerousSql: boolean; + executionModeConfigured: boolean; + executionModePolicyVersion: number | null; + databaseScope: "all" | "selected" | "none"; + allowedDatabases: string[]; + databasePolicies: { databaseName: string; readOnly: boolean; allowDangerousSql: boolean }[]; + }[]; + groupPolicies?: McpGroupPolicy[]; + queryTimeoutSecs?: number | null; + }, + callbacks?: { onSuccess?: () => void; onFailure?: () => void }, +) { if (mcpPolicyControlsDisabled.value) return; mcpPolicySaving.value = true; try { await settingsStore.updateMcpGlobalPolicy(partial); + callbacks?.onSuccess?.(); } catch (e: any) { toast(t("settings.mcpPolicySaveFailed", { error: e?.message || String(e) }), 5000); + callbacks?.onFailure?.(); } finally { mcpPolicySaving.value = false; + // A query-timeout edit can have debounced while another policy write held + // the shared gate. Persist it once that write releases the gate. + if (mcpQueryTimeoutPendingValue !== undefined) flushMcpQueryTimeoutSave(); } } @@ -5041,6 +5116,8 @@ watch( ); onUnmounted(() => { + flushMcpQueryTimeoutSave(); + if (mcpQueryTimeoutSavedStatusTimer !== null) clearTimeout(mcpQueryTimeoutSavedStatusTimer); cleanupPreviewEditor(); resetSettingsSearchState(); }); @@ -8932,7 +9009,15 @@ LIMIT 100;
- +
+ +

+ + + + {{ t(`settings.mcpQueryTimeoutSaveStatus_${mcpQueryTimeoutSaveStatus}`) }} +

+
diff --git a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.mcpQueryTimeout.integration.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.mcpQueryTimeout.integration.spec.ts new file mode 100644 index 0000000000..b5d5674ce4 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.mcpQueryTimeout.integration.spec.ts @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom +// Real-DOM integration for the MCP query-timeout input (#8616). The settings +// dialog reads the fresh value from the native event target in capture phase, +// before Input's passive v-model proxy updates the parent ref. This test mounts the real +// Input component with exactly that wiring and drives it with real DOM events +// to prove the typing -> debounce -> saveMcpPolicy chain persists the fresh +// value (a two-way v-model would make the passive proxy emit asynchronously, +// so the handler would read a stale ref during the same native event). +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp, defineComponent, h, nextTick, type App } from "vue"; +import Input from "@/components/ui/input/Input.vue"; +import { createMcpQueryTimeoutHarness, type McpQueryTimeoutHarness } from "./mcpQueryTimeoutHarness"; +import dialogSource from "../EditorSettingsDialog.vue?raw"; + +describe("EditorSettingsDialog MCP query timeout real-DOM integration", () => { + const mountedApps: App[] = []; + + afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; + vi.useRealTimers(); + }); + + async function mountInput(wiring: { modelValue: { value: string }; onInput: (event: Event) => void }): Promise { + const container = document.createElement("div"); + document.body.append(container); + const app = createApp( + defineComponent({ + setup: () => () => + h(Input, { + modelValue: wiring.modelValue.value, + "onUpdate:modelValue": (v: string) => { + wiring.modelValue.value = v; + }, + onInputCapture: wiring.onInput, + }), + }), + ); + mountedApps.push(app); + app.mount(container); + await nextTick(); + const el = container.querySelector("input"); + if (!el) throw new Error("input not rendered"); + return el as HTMLInputElement; + } + + function harnessFor(saveMcpPolicy: ReturnType): { harness: McpQueryTimeoutHarness; inputRef: { value: string } } { + const inputRef = { value: "" }; + const harness = createMcpQueryTimeoutHarness({ + source: dialogSource, + input: inputRef, + policyQueryTimeoutSecs: null, + saveMcpPolicy, + }); + return { harness, inputRef }; + } + + it("typing into the real Input persists the fresh value after debounce", async () => { + vi.useFakeTimers(); + const saveMcpPolicy = vi.fn(); + const { harness, inputRef } = harnessFor(saveMcpPolicy); + + const el = await mountInput({ + modelValue: inputRef, + onInput: harness.onMcpQueryTimeoutInput, + }); + + // Simulate real typing: set the DOM value and dispatch a native input event. + el.value = "300"; + el.dispatchEvent(new Event("input", { bubbles: true })); + + // The debounce has not elapsed yet: no save. + expect(saveMcpPolicy).not.toHaveBeenCalled(); + + // Elapse the debounce window and assert the FRESH value was saved. + vi.advanceTimersByTime(300); + expect(saveMcpPolicy).toHaveBeenCalledTimes(1); + expect(saveMcpPolicy.mock.calls.at(-1)?.[0]).toEqual({ queryTimeoutSecs: 300 }); + }); + + it("typing then flushing on close persists the fresh value immediately", async () => { + vi.useFakeTimers(); + const saveMcpPolicy = vi.fn(); + const { harness, inputRef } = harnessFor(saveMcpPolicy); + + const el = await mountInput({ + modelValue: inputRef, + onInput: harness.onMcpQueryTimeoutInput, + }); + + el.value = "120"; + el.dispatchEvent(new Event("input", { bubbles: true })); + + // Close path: flush before the debounce elapses. + harness.flushMcpQueryTimeoutSave(); + expect(saveMcpPolicy).toHaveBeenCalledTimes(1); + expect(saveMcpPolicy.mock.calls.at(-1)?.[0]).toEqual({ queryTimeoutSecs: 120 }); + }); + + it("rapid typing in the real DOM coalesces into one save of the last value", async () => { + vi.useFakeTimers(); + const saveMcpPolicy = vi.fn(); + const { harness, inputRef } = harnessFor(saveMcpPolicy); + + const el = await mountInput({ + modelValue: inputRef, + onInput: harness.onMcpQueryTimeoutInput, + }); + + el.value = "1"; + el.dispatchEvent(new Event("input", { bubbles: true })); + vi.advanceTimersByTime(100); + el.value = "12"; + el.dispatchEvent(new Event("input", { bubbles: true })); + vi.advanceTimersByTime(100); + el.value = "123"; + el.dispatchEvent(new Event("input", { bubbles: true })); + vi.advanceTimersByTime(300); + + expect(saveMcpPolicy).toHaveBeenCalledTimes(1); + expect(saveMcpPolicy.mock.calls.at(-1)?.[0]).toEqual({ queryTimeoutSecs: 123 }); + }); +}); diff --git a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.mcpQueryTimeout.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.mcpQueryTimeout.spec.ts index b257367ba1..1729a02f6f 100644 --- a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.mcpQueryTimeout.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.mcpQueryTimeout.spec.ts @@ -1,20 +1,21 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createMcpQueryTimeoutHarness } from "./mcpQueryTimeoutHarness"; const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8"); +function handlerBlock(): string { + const start = dialogSource.indexOf("function onMcpQueryTimeoutInput("); + const end = dialogSource.indexOf("const mcpSelectableConnections", start); + if (start < 0 || end < 0) throw new Error("Missing onMcpQueryTimeoutInput handler block"); + return dialogSource.slice(start, end); +} + // Regression for the MCP query-timeout input: decimal values like 1.5 were // silently Math.round-ed to 2 and saved, contradicting the "non-negative whole // number" label. The UI must reject non-integers with the existing invalid // toast instead of silently rewriting them. describe("EditorSettingsDialog MCP query timeout validation", () => { - function handlerBlock(): string { - const start = dialogSource.indexOf("function onMcpQueryTimeoutInput()"); - const end = dialogSource.indexOf("const mcpSelectableConnections", start); - if (start < 0 || end < 0) throw new Error("Missing onMcpQueryTimeoutInput handler block"); - return dialogSource.slice(start, end); - } - it("rejects non-integers instead of rounding them", () => { const block = handlerBlock(); // The invalid path must test for integer-ness, not just finite + non-negative. @@ -23,14 +24,218 @@ describe("EditorSettingsDialog MCP query timeout validation", () => { expect(block).not.toContain("Math.round(parsed)"); // A decimal input falls through to the invalid toast + revert branch. expect(block).toContain('toast(t("settings.mcpQueryTimeoutInvalid"), 5000)'); - expect(block).toMatch(/mcpQueryTimeoutInput\.value\s*=\s*settingsStore\.mcpGlobalPolicy\.queryTimeoutSecs === null \? "" : String\(settingsStore\.mcpGlobalPolicy\.queryTimeoutSecs\)/); + expect(block).toMatch(/mcpQueryTimeoutInput\.value\s*=\s*reverted/); }); it("keeps null (inherit) and zero (no limit) working", () => { const block = handlerBlock(); - // Empty input still persists null (inherit the connection). - expect(block).toContain("void saveMcpPolicy({ queryTimeoutSecs: null })"); - // A valid integer still reaches saveMcpPolicy with the exact value. - expect(block).toContain("void saveMcpPolicy({ queryTimeoutSecs: parsed })"); + // Empty input still schedules null (inherit the connection). + expect(block).toContain("mcpQueryTimeoutPendingValue = null"); + // A valid integer is scheduled with the exact value. + expect(block).toContain("mcpQueryTimeoutPendingValue = parsed"); + // The invalid branch cancels any pending save instead of persisting garbage. + expect(block).toContain("mcpQueryTimeoutPendingValue = undefined"); + }); +}); + +// Regression for #8616: the input previously bound @change (fires only on +// blur/Enter), so typing a value and closing the window dropped the change and +// the persisted policy kept its old value. The fix binds @input (fires on every +// keystroke), debounces the write, and flushes any pending value on close. +describe("EditorSettingsDialog MCP query timeout persistence", () => { + it("binds @input instead of @change so typing schedules a save immediately", () => { + const inputLine = dialogSource.split("\n").find((line) => line.includes('id="mcp-query-timeout"')); + expect(inputLine).toBeTruthy(); + // The legacy @change binding must not come back. + expect(inputLine).not.toContain("@change"); + // Typing must trigger the save path on every keystroke. + expect(inputLine).toContain('@input.capture="onMcpQueryTimeoutInput"'); + }); + + it("uses capture-phase input handling so the handler reads the fresh native value before Input's passive v-model update", () => { + const inputLine = dialogSource.split("\n").find((line) => line.includes('id="mcp-query-timeout"')); + expect(inputLine).toBeTruthy(); + expect(inputLine).toContain('v-model="mcpQueryTimeoutInput"'); + expect(inputLine).toContain('@input.capture="onMcpQueryTimeoutInput"'); + }); + + it("handler reads the value from the native event target, not the ref", () => { + const block = handlerBlock(); + expect(block).toContain("onMcpQueryTimeoutInput(event: Event)"); + expect(block).toMatch(/const target = event\.currentTarget as HTMLInputElement/); + expect(block).toContain("target.value.trim()"); + }); + + it("debounces the persist so rapid typing coalesces into one write", () => { + const block = handlerBlock(); + expect(block).toContain("MCP_QUERY_TIMEOUT_SAVE_DEBOUNCE_MS"); + expect(block).toMatch(/setTimeout\([\s\S]*MCP_QUERY_TIMEOUT_SAVE_DEBOUNCE_MS\)/); + expect(block).toContain("clearTimeout(mcpQueryTimeoutSaveTimer)"); + expect(block).toContain("flushMcpQueryTimeoutSave()"); + }); + + it("flushes the pending value when the settings dialog closes", () => { + const closeBlockStart = dialogSource.indexOf("function requestCloseSettings("); + const closeBlockEnd = dialogSource.indexOf("function onSettingsRootOpenChange", closeBlockStart); + expect(closeBlockStart).toBeGreaterThan(-1); + expect(closeBlockEnd).toBeGreaterThan(closeBlockStart); + const closeBlock = dialogSource.slice(closeBlockStart, closeBlockEnd); + expect(closeBlock).toContain("flushMcpQueryTimeoutSave()"); + + // The unmount path must also flush, covering a hard window close. + const unmountIndex = dialogSource.lastIndexOf("onUnmounted(() =>"); + expect(unmountIndex).toBeGreaterThan(-1); + const unmountTail = dialogSource.slice(unmountIndex); + expect(unmountTail).toContain("flushMcpQueryTimeoutSave()"); + }); + + it("persists through saveMcpPolicy with the pending value", () => { + const flushBlockStart = dialogSource.indexOf("function flushMcpQueryTimeoutSave()"); + const flushBlockEnd = dialogSource.indexOf("const mcpSelectableConnections", flushBlockStart); + expect(flushBlockStart).toBeGreaterThan(-1); + expect(flushBlockEnd).toBeGreaterThan(flushBlockStart); + const flushBlock = dialogSource.slice(flushBlockStart, flushBlockEnd); + // The debounced timer hands the pending value to the existing save path. + expect(flushBlock).toContain("queryTimeoutSecs: value"); + // A cleared pending value (no recent input) is a no-op. + expect(flushBlock).toContain("mcpQueryTimeoutPendingValue === undefined"); + }); +}); + +// Behavioral coverage for the debounce/flush runtime (review finding): the +// string assertions above cannot catch a broken runtime path, so this harness +// extracts the real debounce block from the .vue source, compiles it, and +// drives it with fake timers + mocked deps to assert actual save behaviour. + +function nativeInputEvent(value: string, badInput = false): Event { + return { currentTarget: { value, validity: { badInput } } } as unknown as Event; +} + +function expectQueryTimeoutSave(saveMcpPolicy: ReturnType, queryTimeoutSecs: number | null) { + expect(saveMcpPolicy).toHaveBeenCalled(); + expect(saveMcpPolicy.mock.calls.at(-1)?.[0]).toEqual({ queryTimeoutSecs }); +} + +describe("EditorSettingsDialog MCP query timeout debounce runtime", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("saves a typed value once the debounce window elapses", () => { + const saveMcpPolicy = vi.fn(); + const harness = createMcpQueryTimeoutHarness({ source: dialogSource, input: { value: "" }, policyQueryTimeoutSecs: null, saveMcpPolicy }); + harness.onMcpQueryTimeoutInput(nativeInputEvent("300")); + expect(saveMcpPolicy).not.toHaveBeenCalled(); + vi.advanceTimersByTime(299); + expect(saveMcpPolicy).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(saveMcpPolicy).toHaveBeenCalledTimes(1); + expectQueryTimeoutSave(saveMcpPolicy, 300); + }); + + it("reports saving immediately and saved after the persistence callback", () => { + const setSaveStatus = vi.fn(); + const saveMcpPolicy = vi.fn((_partial, callbacks?: { onSuccess?: () => void }) => callbacks?.onSuccess?.()); + const harness = createMcpQueryTimeoutHarness({ source: dialogSource, input: { value: "" }, policyQueryTimeoutSecs: null, saveMcpPolicy, setSaveStatus }); + harness.onMcpQueryTimeoutInput(nativeInputEvent("300")); + expect(setSaveStatus).toHaveBeenLastCalledWith("saving"); + harness.flushMcpQueryTimeoutSave(); + expect(setSaveStatus).toHaveBeenLastCalledWith("saved"); + }); + + it("coalesces rapid typing into a single save", () => { + const saveMcpPolicy = vi.fn(); + const harness = createMcpQueryTimeoutHarness({ source: dialogSource, input: { value: "" }, policyQueryTimeoutSecs: null, saveMcpPolicy }); + harness.onMcpQueryTimeoutInput(nativeInputEvent("1")); + vi.advanceTimersByTime(100); + harness.onMcpQueryTimeoutInput(nativeInputEvent("12")); + vi.advanceTimersByTime(100); + harness.onMcpQueryTimeoutInput(nativeInputEvent("123")); + vi.advanceTimersByTime(300); + expect(saveMcpPolicy).toHaveBeenCalledTimes(1); + expectQueryTimeoutSave(saveMcpPolicy, 123); + }); + + it("flush persists a pending value before the debounce elapses", () => { + const saveMcpPolicy = vi.fn(); + const harness = createMcpQueryTimeoutHarness({ source: dialogSource, input: { value: "" }, policyQueryTimeoutSecs: null, saveMcpPolicy }); + harness.onMcpQueryTimeoutInput(nativeInputEvent("120")); + harness.flushMcpQueryTimeoutSave(); + expect(saveMcpPolicy).toHaveBeenCalledTimes(1); + expectQueryTimeoutSave(saveMcpPolicy, 120); + // Flushing again with nothing pending is a no-op. + harness.flushMcpQueryTimeoutSave(); + expect(saveMcpPolicy).toHaveBeenCalledTimes(1); + }); + + it("flush after the timer fired does not double-save", () => { + const saveMcpPolicy = vi.fn(); + const harness = createMcpQueryTimeoutHarness({ source: dialogSource, input: { value: "" }, policyQueryTimeoutSecs: null, saveMcpPolicy }); + harness.onMcpQueryTimeoutInput(nativeInputEvent("60")); + vi.advanceTimersByTime(300); + expect(saveMcpPolicy).toHaveBeenCalledTimes(1); + harness.flushMcpQueryTimeoutSave(); + expect(saveMcpPolicy).toHaveBeenCalledTimes(1); + }); + + it("retains a pending value while another MCP policy mutation holds the gate", () => { + const saveMcpPolicy = vi.fn(); + const policyMutationBlocked = { value: true }; + const harness = createMcpQueryTimeoutHarness({ source: dialogSource, input: { value: "" }, policyQueryTimeoutSecs: null, saveMcpPolicy, policyMutationBlocked }); + harness.onMcpQueryTimeoutInput(nativeInputEvent("60")); + vi.advanceTimersByTime(300); + expect(saveMcpPolicy).not.toHaveBeenCalled(); + + policyMutationBlocked.value = false; + harness.flushMcpQueryTimeoutSave(); + expectQueryTimeoutSave(saveMcpPolicy, 60); + }); + + it("empty input schedules null (inherit the connection)", () => { + const saveMcpPolicy = vi.fn(); + const harness = createMcpQueryTimeoutHarness({ source: dialogSource, input: { value: "" }, policyQueryTimeoutSecs: 300, saveMcpPolicy }); + harness.onMcpQueryTimeoutInput(nativeInputEvent("")); + harness.flushMcpQueryTimeoutSave(); + expectQueryTimeoutSave(saveMcpPolicy, null); + }); + + it("zero input persists 0 (no limit)", () => { + const saveMcpPolicy = vi.fn(); + const harness = createMcpQueryTimeoutHarness({ source: dialogSource, input: { value: "" }, policyQueryTimeoutSecs: null, saveMcpPolicy }); + harness.onMcpQueryTimeoutInput(nativeInputEvent("0")); + harness.flushMcpQueryTimeoutSave(); + expectQueryTimeoutSave(saveMcpPolicy, 0); + }); + + it("invalid input cancels the pending save and reverts the field", () => { + const saveMcpPolicy = vi.fn(); + const toast = vi.fn(); + const input = { value: "1.5" }; + const harness = createMcpQueryTimeoutHarness({ source: dialogSource, input, policyQueryTimeoutSecs: null, saveMcpPolicy, toast }); + const event = nativeInputEvent("1.5"); + harness.onMcpQueryTimeoutInput(event); + // Invalid input must not schedule anything. + vi.advanceTimersByTime(300); + expect(saveMcpPolicy).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledTimes(1); + // The bound ref is reverted to the persisted value ("" == inherit). + expect(input.value).toBe(""); + // The native input is reverted too (one-way binding needs the DOM write). + expect((event.currentTarget as { value: string }).value).toBe(""); + }); + + it("does not treat a bad number-input intermediate state as inherit", () => { + const saveMcpPolicy = vi.fn(); + const harness = createMcpQueryTimeoutHarness({ source: dialogSource, input: { value: "" }, policyQueryTimeoutSecs: 90, saveMcpPolicy }); + harness.onMcpQueryTimeoutInput(nativeInputEvent("45")); + vi.advanceTimersByTime(100); + harness.onMcpQueryTimeoutInput(nativeInputEvent("", true)); + vi.advanceTimersByTime(200); + expectQueryTimeoutSave(saveMcpPolicy, 45); + expect(saveMcpPolicy.mock.calls.some(([partial]) => partial.queryTimeoutSecs === null)).toBe(false); }); }); diff --git a/apps/desktop/src/components/editor/__tests__/mcpQueryTimeoutHarness.ts b/apps/desktop/src/components/editor/__tests__/mcpQueryTimeoutHarness.ts new file mode 100644 index 0000000000..3a50f01a87 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/mcpQueryTimeoutHarness.ts @@ -0,0 +1,43 @@ +// Shared test helper: compiles the MCP query-timeout debounce block extracted +// from EditorSettingsDialog.vue into a runnable harness. The source string is +// passed in by the caller so this module stays free of node:fs — the happy-dom +// integration spec cannot load node built-ins. +import ts from "typescript"; + +export interface McpQueryTimeoutHarness { + onMcpQueryTimeoutInput: (event: Event) => void; + flushMcpQueryTimeoutSave: () => void; +} + +export function extractMcpQueryTimeoutDebounceBlock(source: string): string { + const start = source.indexOf("const MCP_QUERY_TIMEOUT_SAVE_DEBOUNCE_MS"); + const end = source.indexOf("const mcpSelectableConnections", start); + if (start < 0 || end < 0) throw new Error("Missing MCP query timeout debounce block"); + return source.slice(start, end); +} + +export function createMcpQueryTimeoutHarness(options: { + source: string; + input: { value: string }; + policyQueryTimeoutSecs: number | null; + saveMcpPolicy: (partial: { queryTimeoutSecs: number | null }, callbacks?: { onSuccess?: () => void; onFailure?: () => void }) => void; + policyMutationBlocked?: { value: boolean }; + setSaveStatus?: (status: "idle" | "saving" | "saved" | "failed") => void; + toast?: (message: string, duration: number) => void; + t?: (key: string) => string; +}): McpQueryTimeoutHarness { + const block = extractMcpQueryTimeoutDebounceBlock(options.source); + const javascript = ts.transpileModule(block, { + compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const factory = new Function("mcpQueryTimeoutInput", "settingsStore", "saveMcpPolicy", "toast", "t", "mcpPolicyControlsDisabled", "setMcpQueryTimeoutSaveStatus", `${javascript}\nreturn { onMcpQueryTimeoutInput, flushMcpQueryTimeoutSave };`); + return factory( + options.input, + { mcpGlobalPolicy: { queryTimeoutSecs: options.policyQueryTimeoutSecs } }, + options.saveMcpPolicy, + options.toast ?? (() => {}), + options.t ?? ((key: string) => key), + options.policyMutationBlocked ?? { value: false }, + options.setSaveStatus ?? (() => {}), + ) as McpQueryTimeoutHarness; +} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index fdbf4392db..5ab25f75c7 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -7394,6 +7394,9 @@ export default { mcpQueryTimeout: "Query timeout (seconds)", mcpQueryTimeoutDescription: "Leave empty to inherit the connection's query timeout. 0 = no limit. A positive value overrides the connection setting for every MCP query.", mcpQueryTimeoutInvalid: "Enter a non-negative whole number of seconds, or leave empty to inherit the connection setting.", + mcpQueryTimeoutSaveStatus_saving: "Saving...", + mcpQueryTimeoutSaveStatus_saved: "Saved", + mcpQueryTimeoutSaveStatus_failed: "Save failed", mcpCapabilityAlwaysEnforced: "Every mode remains subject to the connection allowlist, connection read-only protection, production protection, and database-account permissions.", mcpPolicySaveFailed: "Failed to save the MCP access policy: {error}", mcpPolicyLoadFailed: "Failed to load the MCP access policy: {error}", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 30ff402c6b..5f42f8fc5d 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7204,6 +7204,9 @@ export default withEnglishFallback({ shortcutConvertNamingStyle: "Cambiar estilo de nomenclatura", dataGridKeepFilterEditorExpanded: "Mantener expandido el editor de filtros", dataGridKeepFilterEditorExpandedDescription: "Mantiene siempre expandido el editor en las vistas Condiciones y Texto.", + mcpQueryTimeoutSaveStatus_saving: "Guardando...", + mcpQueryTimeoutSaveStatus_saved: "Guardado", + mcpQueryTimeoutSaveStatus_failed: "Error al guardar", }, driverStore: { jreDirRemoveFailed: "No se pudo eliminar el directorio JRE antiguo: {path} (error original: {error})", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 862a8c209b..9014837c88 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7204,6 +7204,9 @@ export default withEnglishFallback({ shortcutConvertNamingStyle: "Cambia stile di denominazione", dataGridKeepFilterEditorExpanded: "Mantieni espanso l'editor dei filtri", dataGridKeepFilterEditorExpandedDescription: "Mantiene sempre espanso l'editor nelle viste Condizioni e Testo.", + mcpQueryTimeoutSaveStatus_saving: "Salvataggio in corso...", + mcpQueryTimeoutSaveStatus_saved: "Salvato", + mcpQueryTimeoutSaveStatus_failed: "Salvataggio non riuscito", }, driverStore: { jreDirRemoveFailed: "Impossibile rimuovere la vecchia directory JRE: {path} (errore originale: {error})", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 6eff0224bb..d7f4b60b7b 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7256,6 +7256,9 @@ export default withEnglishFallback({ shortcutConvertNamingStyle: "命名スタイルの切り替え", dataGridKeepFilterEditorExpanded: "フィルターエディターを常に展開する", dataGridKeepFilterEditorExpandedDescription: "条件ビューとテキストビューでエディターを常に展開します。", + mcpQueryTimeoutSaveStatus_saving: "保存中...", + mcpQueryTimeoutSaveStatus_saved: "保存済み", + mcpQueryTimeoutSaveStatus_failed: "保存失敗", }, driverStore: { jreDirRemoveFailed: "古い JRE ディレクトリを削除できませんでした: {path}(元のエラー: {error})", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index d7bdbc5625..c2ed1919d8 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6928,6 +6928,9 @@ export default withEnglishFallback({ shortcutConvertNamingStyle: "네이밍 스타일 전환", dataGridKeepFilterEditorExpanded: "필터 편집기 항상 펼치기", dataGridKeepFilterEditorExpandedDescription: "조건 및 텍스트 보기에서 편집기를 항상 펼칩니다.", + mcpQueryTimeoutSaveStatus_saving: "저장 중...", + mcpQueryTimeoutSaveStatus_saved: "저장됨", + mcpQueryTimeoutSaveStatus_failed: "저장 실패", }, driverStore: { jreDirRemoveFailed: "이전 JRE 디렉터리 제거 실패: {path} (원래 오류: {error})", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1e8d992e43..58e10e0729 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7206,6 +7206,9 @@ export default withEnglishFallback({ shortcutConvertNamingStyle: "Alternar estilo de nomenclatura", dataGridKeepFilterEditorExpanded: "Manter o editor de filtros expandido", dataGridKeepFilterEditorExpandedDescription: "Mantém o editor sempre expandido nas visualizações Condições e Texto.", + mcpQueryTimeoutSaveStatus_saving: "Salvando...", + mcpQueryTimeoutSaveStatus_saved: "Salvo", + mcpQueryTimeoutSaveStatus_failed: "Falha ao salvar", }, driverStore: { jreDirRemoveFailed: "Não foi possível remover o diretório JRE antigo: {path} (erro original: {error})", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 1db74c43e0..0ea9bd98d7 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -7379,6 +7379,9 @@ export default withEnglishFallback({ mcpQueryTimeout: "查询超时(秒)", mcpQueryTimeoutDescription: "留空则继承连接的查询超时设置。0 = 不限时。正数会覆盖连接设置,对所有 MCP 查询生效。", mcpQueryTimeoutInvalid: "请输入非负整数秒数,或留空以继承连接设置。", + mcpQueryTimeoutSaveStatus_saving: "保存中...", + mcpQueryTimeoutSaveStatus_saved: "已保存", + mcpQueryTimeoutSaveStatus_failed: "保存失败", mcpCapabilityAlwaysEnforced: "所有模式仍受连接允许范围、连接只读、生产库保护和数据库账号权限约束。", mcpPolicySaveFailed: "保存 MCP 访问策略失败:{error}", mcpPolicyLoadFailed: "加载 MCP 访问策略失败:{error}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9ac16c51ff..790a132ef9 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6521,6 +6521,9 @@ export default withEnglishFallback({ shortcutConvertNamingStyle: "切換命名風格", dataGridKeepFilterEditorExpanded: "篩選編輯器常駐展開", dataGridKeepFilterEditorExpandedDescription: "始終在條件檢視和文字檢視中展開編輯器。", + mcpQueryTimeoutSaveStatus_saving: "儲存中...", + mcpQueryTimeoutSaveStatus_saved: "已儲存", + mcpQueryTimeoutSaveStatus_failed: "儲存失敗", }, driverStore: { jreDirRemoveFailed: "無法刪除舊的 JRE 目錄:{path}(原始錯誤:{error})",