diff --git a/apps/desktop/src/components/common/QueryErrorActions.vue b/apps/desktop/src/components/common/QueryErrorActions.vue index c27f385b9a..924621594c 100644 --- a/apps/desktop/src/components/common/QueryErrorActions.vue +++ b/apps/desktop/src/components/common/QueryErrorActions.vue @@ -2,10 +2,12 @@ import { Bot, Wrench } from "@lucide/vue"; import { useI18n } from "vue-i18n"; import { Button } from "@/components/ui/button"; +import type { BackendError } from "@/lib/backend/errorUtils"; import { isQueryTimeoutErrorMessage } from "@/lib/sql/queryError"; const props = defineProps<{ errorMessage: string; + backendError?: BackendError; connectionId?: string; }>(); @@ -18,7 +20,7 @@ const { t } = useI18n(); diff --git a/apps/desktop/src/lib/__tests__/sql/queryError.spec.ts b/apps/desktop/src/lib/__tests__/sql/queryError.spec.ts index 5015d9a5ac..0ea7f0136b 100644 --- a/apps/desktop/src/lib/__tests__/sql/queryError.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/queryError.spec.ts @@ -26,6 +26,33 @@ describe("isQueryTimeoutErrorMessage", () => { expect(isQueryTimeoutErrorMessage("Agent RPC error (-1): Agent RPC call timed out (120s)")).toBe(true); }); + it("detects structured query operation timeouts before localized text matching", () => { + const timeoutError = { + version: 1, + code: "DBX-JDBC-2002", + messageKey: "backendErrors.jdbc.operationTimedOut", + messageParams: { stage: "execute" }, + source: "jdbcAgent", + operationOutcome: "unknown" as const, + } as const; + + expect(isQueryTimeoutErrorMessage("数据库操作超时(阶段:execute)。", timeoutError)).toBe(true); + expect(isQueryTimeoutErrorMessage("Database operation timed out (stage: fetch).", { ...timeoutError, messageParams: { stage: "fetch" } })).toBe(true); + }); + + it("does not offer query timeout settings for structured infrastructure timeouts", () => { + const timeoutError = { + version: 1, + code: "DBX-JDBC-2001", + messageKey: "backendErrors.jdbc.operationTimedOut", + messageParams: { stage: "connect" }, + source: "jdbcAgent", + operationOutcome: "not_started" as const, + } as const; + + expect(isQueryTimeoutErrorMessage("Database operation timed out (stage: connect).", timeoutError)).toBe(false); + }); + it("does not classify unrelated errors as query timeouts", () => { expect(isQueryTimeoutErrorMessage('syntax error at or near "select"')).toBe(false); expect(isQueryTimeoutErrorMessage("Connection timed out while loading databases")).toBe(false); diff --git a/apps/desktop/src/lib/sql/queryError.ts b/apps/desktop/src/lib/sql/queryError.ts index d8d8fcd1e2..01501e0489 100644 --- a/apps/desktop/src/lib/sql/queryError.ts +++ b/apps/desktop/src/lib/sql/queryError.ts @@ -1,4 +1,12 @@ -export function isQueryTimeoutErrorMessage(message: string): boolean { +import type { BackendError } from "@/lib/backend/errorUtils"; + +const QUERY_TIMEOUT_STAGES = new Set(["execute", "fetch"]); + +export function isQueryTimeoutErrorMessage(message: string, backendError?: BackendError): boolean { + if (backendError?.messageKey === "backendErrors.jdbc.operationTimedOut") { + const stage = String(backendError.messageParams.stage ?? "").toLowerCase(); + return QUERY_TIMEOUT_STAGES.has(stage); + } const lower = message.toLowerCase(); if (lower.includes("query timed out") || lower.includes("查询超时") || lower.includes("查詢逾時")) return true; // Agent RPC client-side timeout (tokio::time::timeout in agent_driver.rs). This is the diff --git a/packages/app-tests/queryTimeout.test.ts b/packages/app-tests/queryTimeout.test.ts index 6be396ad92..3ffbc10ec3 100644 --- a/packages/app-tests/queryTimeout.test.ts +++ b/packages/app-tests/queryTimeout.test.ts @@ -1,7 +1,10 @@ import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; import { test } from "vitest"; import { DEFAULT_QUERY_TIMEOUT_SECS, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "../../apps/desktop/src/lib/sql/queryTimeout.ts"; +const contentAreaSource = readFileSync("apps/desktop/src/components/layout/ContentArea.vue", "utf8"); + test("queryTimeoutSecsForConnection falls back to the default timeout", () => { assert.equal(DEFAULT_QUERY_TIMEOUT_SECS, 30); assert.equal(queryTimeoutSecsForConnection(undefined), DEFAULT_QUERY_TIMEOUT_SECS); @@ -17,3 +20,14 @@ test("frontend query timeout scales with SQL statement count", () => { assert.equal(frontendQueryTimeoutSecsForSql("/* prep */\nINSERT INTO users VALUES (1);\n-- keep batching\nINSERT INTO users VALUES (2);", "mysql", 30), 120); assert.equal(frontendQueryTimeoutSecsForSql("INSERT INTO users VALUES (1); INSERT INTO users VALUES (2);", "mysql", 0), 0); }); + +test("query timeout actions target the connection that produced the result", () => { + const resultErrorActions = [...contentAreaSource.matchAll(//g)].map((match) => match[0]).filter((tag) => tag.includes(':backend-error="activeTab.result.error"')); + + assert.equal(resultErrorActions.length, 2); + for (const tag of resultErrorActions) { + assert.match(tag, /:connection-id="activeResultConnectionId"/); + assert.match(tag, /emit\('openConnectionSettings', activeResultConnectionId, 'advanced'\)/); + assert.doesNotMatch(tag, /activeTab\.connectionId/); + } +});