mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:47:00 +00:00
fix(mcp): persist query timeout edits reliably
This commit is contained in:
@@ -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">
|
||||
|
||||
+123
@@ -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 });
|
||||
});
|
||||
});
|
||||
+218
-13
@@ -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;
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -7256,6 +7256,9 @@ export default withEnglishFallback({
|
||||
shortcutConvertNamingStyle: "命名スタイルの切り替え",
|
||||
dataGridKeepFilterEditorExpanded: "フィルターエディターを常に展開する",
|
||||
dataGridKeepFilterEditorExpandedDescription: "条件ビューとテキストビューでエディターを常に展開します。",
|
||||
mcpQueryTimeoutSaveStatus_saving: "保存中...",
|
||||
mcpQueryTimeoutSaveStatus_saved: "保存済み",
|
||||
mcpQueryTimeoutSaveStatus_failed: "保存失敗",
|
||||
},
|
||||
driverStore: {
|
||||
jreDirRemoveFailed: "古い JRE ディレクトリを削除できませんでした: {path}(元のエラー: {error})",
|
||||
|
||||
@@ -6928,6 +6928,9 @@ export default withEnglishFallback({
|
||||
shortcutConvertNamingStyle: "네이밍 스타일 전환",
|
||||
dataGridKeepFilterEditorExpanded: "필터 편집기 항상 펼치기",
|
||||
dataGridKeepFilterEditorExpandedDescription: "조건 및 텍스트 보기에서 편집기를 항상 펼칩니다.",
|
||||
mcpQueryTimeoutSaveStatus_saving: "저장 중...",
|
||||
mcpQueryTimeoutSaveStatus_saved: "저장됨",
|
||||
mcpQueryTimeoutSaveStatus_failed: "저장 실패",
|
||||
},
|
||||
driverStore: {
|
||||
jreDirRemoveFailed: "이전 JRE 디렉터리 제거 실패: {path} (원래 오류: {error})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -6521,6 +6521,9 @@ export default withEnglishFallback({
|
||||
shortcutConvertNamingStyle: "切換命名風格",
|
||||
dataGridKeepFilterEditorExpanded: "篩選編輯器常駐展開",
|
||||
dataGridKeepFilterEditorExpandedDescription: "始終在條件檢視和文字檢視中展開編輯器。",
|
||||
mcpQueryTimeoutSaveStatus_saving: "儲存中...",
|
||||
mcpQueryTimeoutSaveStatus_saved: "已儲存",
|
||||
mcpQueryTimeoutSaveStatus_failed: "儲存失敗",
|
||||
},
|
||||
driverStore: {
|
||||
jreDirRemoveFailed: "無法刪除舊的 JRE 目錄:{path}(原始錯誤:{error})",
|
||||
|
||||
Reference in New Issue
Block a user