fix(mcp): persist query timeout edits reliably

This commit is contained in:
Abeautifulsnow
2026-09-10 18:04:05 +08:00
committed by GitHub
parent 4fce108247
commit a9b4470944
12 changed files with 525 additions and 45 deletions
@@ -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<McpQueryTimeoutSaveStatus>("idle");
let mcpQueryTimeoutSavedStatusTimer: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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;</pre
</div>
<div class="grid items-center gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(12rem,18rem)]">
<Label id="mcp-query-timeout-label">{{ t("settings.mcpQueryTimeout") }}</Label>
<Input id="mcp-query-timeout" v-model="mcpQueryTimeoutInput" type="number" min="0" step="1" inputmode="numeric" placeholder="0" :disabled="mcpPolicyControlsDisabled" @change="onMcpQueryTimeoutInput" />
<div class="space-y-1">
<Input id="mcp-query-timeout" v-model="mcpQueryTimeoutInput" type="number" min="0" step="1" inputmode="numeric" placeholder="0" :disabled="mcpPolicyControlsDisabled" @input.capture="onMcpQueryTimeoutInput" />
<p v-if="mcpQueryTimeoutSaveStatus !== 'idle'" class="flex h-4 items-center justify-end gap-1 text-[11px] text-muted-foreground" role="status" aria-live="polite">
<Loader2 v-if="mcpQueryTimeoutSaveStatus === 'saving'" class="size-3 animate-spin" />
<Check v-else-if="mcpQueryTimeoutSaveStatus === 'saved'" class="size-3 text-emerald-600 dark:text-emerald-400" />
<AlertTriangle v-else class="size-3 text-destructive" />
{{ t(`settings.mcpQueryTimeoutSaveStatus_${mcpQueryTimeoutSaveStatus}`) }}
</p>
</div>
</div>
</div>
<div v-if="mcpTransportTab === 'stdio'" class="space-y-3">
@@ -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<HTMLInputElement> {
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<typeof vi.fn>): { 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 });
});
});
@@ -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<typeof vi.fn>, 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);
});
});
@@ -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;
}
+3
View File
@@ -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}",
+3
View File
@@ -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})",
+3
View File
@@ -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})",
+3
View File
@@ -7256,6 +7256,9 @@ export default withEnglishFallback({
shortcutConvertNamingStyle: "命名スタイルの切り替え",
dataGridKeepFilterEditorExpanded: "フィルターエディターを常に展開する",
dataGridKeepFilterEditorExpandedDescription: "条件ビューとテキストビューでエディターを常に展開します。",
mcpQueryTimeoutSaveStatus_saving: "保存中...",
mcpQueryTimeoutSaveStatus_saved: "保存済み",
mcpQueryTimeoutSaveStatus_failed: "保存失敗",
},
driverStore: {
jreDirRemoveFailed: "古い JRE ディレクトリを削除できませんでした: {path}(元のエラー: {error})",
+3
View File
@@ -6928,6 +6928,9 @@ export default withEnglishFallback({
shortcutConvertNamingStyle: "네이밍 스타일 전환",
dataGridKeepFilterEditorExpanded: "필터 편집기 항상 펼치기",
dataGridKeepFilterEditorExpandedDescription: "조건 및 텍스트 보기에서 편집기를 항상 펼칩니다.",
mcpQueryTimeoutSaveStatus_saving: "저장 중...",
mcpQueryTimeoutSaveStatus_saved: "저장됨",
mcpQueryTimeoutSaveStatus_failed: "저장 실패",
},
driverStore: {
jreDirRemoveFailed: "이전 JRE 디렉터리 제거 실패: {path} (원래 오류: {error})",
+3
View File
@@ -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})",
+3
View File
@@ -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}",
+3
View File
@@ -6521,6 +6521,9 @@ export default withEnglishFallback({
shortcutConvertNamingStyle: "切換命名風格",
dataGridKeepFilterEditorExpanded: "篩選編輯器常駐展開",
dataGridKeepFilterEditorExpandedDescription: "始終在條件檢視和文字檢視中展開編輯器。",
mcpQueryTimeoutSaveStatus_saving: "儲存中...",
mcpQueryTimeoutSaveStatus_saved: "已儲存",
mcpQueryTimeoutSaveStatus_failed: "儲存失敗",
},
driverStore: {
jreDirRemoveFailed: "無法刪除舊的 JRE 目錄:{path}(原始錯誤:{error})",