test: remove source-text assertion specs

This commit is contained in:
t8y2
2026-10-16 21:14:10 +08:00
parent 8379680404
commit 854077dee8
54 changed files with 8 additions and 1892 deletions
-11
View File
@@ -5,7 +5,6 @@ import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const indexSource = readFileSync(resolve(process.cwd(), "apps/desktop/index.html"), "utf8");
const mainSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/main.ts"), "utf8");
function installStartupInputGuard() {
const script = indexSource.match(/<script data-dbx-startup-input-guard>([\s\S]*?)<\/script>/)?.[1];
@@ -30,14 +29,4 @@ describe("startup input guard", () => {
window.dispatchEvent(readyEscape);
expect(readyEscape.defaultPrevented).toBe(false);
});
it("installs before the application module and is released after mount", () => {
expect(indexSource.indexOf("data-dbx-startup-input-guard")).toBeLessThan(indexSource.indexOf('src="/src/main.ts"'));
// The engine-compatibility gate releases the guard earlier on the blocked path, so search
// for the release that belongs to mounting instead of the first one in the file.
const mountIndex = mainSource.indexOf('app.mount("#root")');
expect(mountIndex).toBeGreaterThan(-1);
expect(mainSource.indexOf('window.dispatchEvent(new Event("dbx:startup-ready"))', mountIndex)).toBeGreaterThan(mountIndex);
});
});
@@ -1,54 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const services = readFileSync(new URL("../ConsulServices.vue", import.meta.url), "utf8");
const health = readFileSync(new URL("../ConsulHealth.vue", import.meta.url), "utf8");
const scope = readFileSync(new URL("../ConsulScope.vue", import.meta.url), "utf8");
const keyBrowser = readFileSync(new URL("../ConsulKeyBrowser.vue", import.meta.url), "utf8");
const mesh = readFileSync(new URL("../ConsulMesh.vue", import.meta.url), "utf8");
const tools = readFileSync(new URL("../ConsulTools.vue", import.meta.url), "utf8");
const operator = readFileSync(new URL("../ConsulOperator.vue", import.meta.url), "utf8");
const kvBrowser = readFileSync(new URL("../../kv/KvKeyBrowser.vue", import.meta.url), "utf8");
describe("Consul Catalog and Health watch views", () => {
it("renders the complete Enterprise delete impact inventory", () => {
for (const resource of ["kvKeys", "healthChecks", "sessions", "intentions", "peerings", "aclTokens", "aclPolicies", "aclRoles", "aclAuthMethods", "aclBindingRules"]) {
expect(scope).toContain(`value.${resource}`);
}
expect(scope).toContain("impact.filteredByAcls");
expect(scope).toContain("impact.unavailableResources");
});
it("gates Agent writes on an explicit target that matches the Agent node", () => {
for (const source of [services, health]) {
expect(source).toContain("config.agentTarget || config.agent_target");
expect(source).toContain(':disabled="!canAgentWrite"');
}
expect(services).toContain("consulAgentWriteBlockedReason(store.getConfig(props.connectionId), identity.value?.node)");
expect(services).toContain("const canAgentWrite = computed(() => agentWriteBlockedReason.value === null);");
expect(health).toContain("consulAgentWriteTargetSafe(store.getConfig(props.connectionId), identity.value?.node)");
});
it("marks the specific locked Key and Session in a prefix-delete preview", () => {
expect(keyBrowser).toContain("'session' in row && row.session ? 'bg-destructive/5' : ''");
expect(keyBrowser).toContain('v-if="\'session\' in row && row.session" variant="destructive"');
expect(keyBrowser).toContain('t("consul.ui.sessionValue", { id: row.session })');
});
it("opens the safe prefix-delete preview from a Consul directory context menu", () => {
expect(keyBrowser).toContain(':on-delete-prefix="openDeletePrefix"');
expect(keyBrowser).toContain('function openDeletePrefix(prefix = "")');
expect(keyBrowser).not.toContain('@click="openDeletePrefix"');
expect(kvBrowser).toContain("if (nodeIsExpandable(node) && props.onDeletePrefix)");
expect(kvBrowser).toContain("action: () => props.onDeletePrefix?.(nodePath(node))");
});
it("preserves Exported Services CAS state and capability-gates advanced writes", () => {
expect(mesh).toContain('consulMeshConfigList(props.connectionId, "exported-services")');
expect(mesh).toContain("editingExported.value?.modifyIndex || 0");
expect(tools).toContain("canWriteQueries");
expect(tools).toContain("canFireEvents");
expect(operator).toContain("keyringWriteVisible");
expect(operator).toContain("props.capabilities?.operatorKeyring");
});
});
@@ -1,171 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Regression coverage for https://github.com/t8y2/dbx/issues/12075:
//
// The Ask/Agent mode + action picker used to be two panel-level refs with no
// per-conversation owner and no persistence, so switching conversations carried
// one chat's choice into another, closing the AI panel reset it to the settings
// default ("Ask · 通用问答"), and no conversation ever reopened with its own
// choice.
//
// AiAssistant.vue is a ~6000-line SFC the suite never mounts, so the wiring that
// gives each conversation its own selection is pinned as text, matching the
// #9902 pattern in `AiAssistant.conversationBinding.spec.ts`. The rules
// themselves are unit-tested in `lib/ai/__tests__/aiConversationPicker.spec.ts`,
// and the restore/record order in
// `lib/ai/__tests__/aiConversationPickerState.spec.ts`.
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
const rules = readFileSync(new URL("../../../lib/ai/aiConversationPicker.ts", import.meta.url), "utf8");
const controllerSource = readFileSync(new URL("../../../lib/ai/aiConversationPickerState.ts", import.meta.url), "utf8");
/**
* The `{ … }` block that opens on `signature`'s own line. Every pinned signature
* below keeps its opening brace as the last one on that line — which is also how
* a return type such as `): { mode: string } {` stays unambiguous.
*/
function bodyAt(signature: string): string {
const start = source.indexOf(signature);
expect(start, `expected to find "${signature}" in AiAssistant.vue`).toBeGreaterThanOrEqual(0);
const braceStart = source.lastIndexOf("{", source.indexOf("\n", start));
expect(source.slice(braceStart, braceStart + 1), `expected "${signature}" to open a block on its line`).toBe("{");
let depth = 0;
for (let i = braceStart; i < source.length; i++) {
if (source[i] === "{") depth++;
else if (source[i] === "}") {
depth--;
if (depth === 0) return source.slice(braceStart, i + 1);
}
}
throw new Error(`unbalanced braces reading body of "${signature}"`);
}
describe("AI conversation owns its mode + action selection (#12075)", () => {
it("keeps the picker selection per conversation, keyed like the skill state", () => {
expect(source).toContain("const conversationPickerSelections = createReactiveMap(new Map<string, AiPickerSelection>());");
// The unsaved chat is a single slot, so two drafts can never trade values (R4).
expect(bodyAt("function pickerSelectionKey()")).toContain("conversationId.value || unsavedConversationKey.value");
});
it("restores the arriving conversation's own selection instead of leaving the previous one", () => {
const body = bodyAt("function selectConversation(conv: AiConversation)");
expect(body).toContain("restoreConversationPicker(conv)");
// The plugin-only branch that always forced `general` was replaced: the
// plugin rules are one of the cases `resolveConversationPicker` replays.
expect(body).not.toContain('activeAction.value = "general"');
expect(bodyAt("function restoreConversationPicker(conv: AiConversation)")).toContain("resolveConversationPicker(pickerRecordFor(conv), pickerAmbient())");
});
it("leaves the new-chat starting point exactly as it was (R3)", () => {
const body = bodyAt("function startNewChat()");
expect(body).toContain("picker.restoreDefault()");
// The settings semantics must survive: `restoreDefault` lands on the
// settings default mode and, with the Settings > AI toggle on, on `auto`.
expect(body).not.toContain("assistantMode.value =");
expect(body).not.toContain("activeAction.value =");
expect(controllerSource).toContain("const next = { mode: ambient().defaultMode, action: resolveDefaultPickerSelection(ambient().defaultMode, ambient()) };");
expect(rules).toContain('return ambient.defaultAutoRouting ? "auto" : defaultActionForMode(mode);');
});
it("runs another conversation's retry and queued input on that conversation's own values (R7)", () => {
const retry = bodyAt("function retryConversationRun(");
expect(retry).toContain("const retrySelection = conversationPickerSelectionFor(convId);");
expect(retry).toContain("mode: retrySelection.mode, action: retrySelection.action");
// Never the visible chat's picker refs.
expect(retry).not.toContain("mode: assistantMode.value");
// The startup restore of persisted "send later" inputs does the same.
expect(source).toContain("const queuedSelection = conversationPickerSelectionFor(conversation.id);");
expect(source).not.toContain("queuedInputs.set(conversation.id, { text: conversation.queuedInput, mode: assistantMode.value, action: activeAction.value });");
});
it("records only a real picker operation, never a restore or a capability limit", () => {
const controller = bodyAt("const picker = createAiConversationPickerController({");
expect(controller).toContain("onUserSelection: (selection) => {");
expect(controller).toContain("conversationPickerSelections.set(pickerSelectionKey(), selection);");
// A pick on an existing conversation must reach disk immediately: closing the
// panel unmounts the component and drops the in-memory map.
expect(controller).toContain("if (conversationId.value) void persistConversation();");
// Capability limits go through `applyCapabilities`, which only shapes what is
// shown; `restore` never calls the recording hook.
expect(bodyAt("function restoreConversationPicker(conv: AiConversation)")).toContain("applyCapabilities(pending, pickerAmbient())");
expect(rules).toContain('if (ambient.vectorDb) action = "generate";');
expect(controllerSource).toContain("function setPair(desired: AiPickerSelection, recorded?: AiPickerSelection) {");
expect(controllerSource).toContain("if (recorded) options.onUserSelection?.(recorded);");
});
it("persists the RECORDED pair with the conversation, not the constrained one", () => {
const snapshot = bodyAt("function pickerSnapshotFor(");
expect(snapshot).toContain("conversationPickerSelections.get(targetConversationId)");
expect(snapshot).toContain("return { assistantMode: existing?.assistantMode, assistantAction: existing?.assistantAction };");
// The raw source, reused by every path that rewrites a whole record.
expect(bodyAt("function buildConversationSnapshot(")).toContain("...pickerSnapshotFor(targetConversationId),");
expect(bodyAt("async function persistPendingInputRecovery(")).toContain("...pickerSnapshotFor(conversation.id),");
});
it("moves an unsaved chat's selection onto the id it adopts, in the same tick as send (R4)", () => {
const migrate = bodyAt("function migrateUnsavedPickerSelection(id: string)");
expect(migrate).toContain("conversationPickerSelections.delete(unsavedKey);");
expect(migrate).toContain("conversationPickerSelections.set(id, selection);");
const watcher = bodyAt("watch(conversationId, (id, previous) => {");
expect(watcher).toContain("migrateUnsavedPickerSelection(id);");
// A draft can never be reopened, so its state moves only onto an id this panel
// minted — never onto a record that already exists, which is what a switch to
// a conversation from the history list looks like (#12075 review).
expect(watcher).toContain("if (conversations.value.some((conversation) => conversation.id === id)) return;");
// `send()` mints the id and reads the selection before the watcher flushes,
// and it only does so on a mint — never for an id that already has a record.
const send = bodyAt("async function send()");
expect(send).toContain("const mintedConversationId = !auto && !conversationId.value;");
expect(send).toContain("if (mintedConversationId) {");
expect(send).toContain("migrateUnsavedPickerSelection(conversationId.value);");
// Queued input takes the same route when it mints the id itself.
expect(bodyAt("function queueInput()")).toContain("migrateUnsavedPickerSelection(conversationId.value);");
});
it("abandons a draft's state instead of carrying it into the next conversation", () => {
const discard = bodyAt("function discardUnsavedDraftState()");
expect(discard).toContain("conversationPickerSelections.delete(unsavedKey);");
expect(discard).toContain("conversationLoadedSkillIds.delete(unsavedKey);");
expect(discard).toContain("unsavedConversationKey.value = `unsaved:${uuid()}`;");
// A replaced draft (new chat) and an abandoned one (opening a stored
// conversation) both go through the same drop, so the two cannot drift.
expect(bodyAt("function clearMessages()")).toContain("discardUnsavedDraftState();");
expect(bodyAt("function selectConversation(conv: AiConversation)")).toContain("if (!conversationId.value) discardUnsavedDraftState();");
});
it("drops a deleted conversation's selection with the rest of its state", () => {
expect(bodyAt("async function performDeleteConversation(id: string)")).toContain("conversationPickerSelections.delete(id);");
});
it("routes every picker entry point through the controller", () => {
// Mode tab, action item, slash command, the "Auto · <action>" chip, the
// "Fix with AI" trigger and the plugin request all move the pair together.
expect(bodyAt('function switchModeActionTab(mode: "ask" | "agent")')).toContain("picker.selectMode(mode);");
expect(bodyAt("function selectAction(action: AiActionSelection)")).toContain("picker.selectAction(action);");
expect(bodyAt("function switchToRoutedAction(action: AiAction | null | undefined)")).toContain("picker.select({");
expect(bodyAt("function triggerAction(action: AiAction, instruction?: string)")).toContain("picker.select({");
expect(bodyAt("function openPluginConversation(request: AiPluginConversationRequest)")).toContain("picker.select({");
// The bare refs are no longer written outside the controller, and the
// hand-rolled reset handshake is gone with them.
expect(source).not.toContain("activeAction.value = resolveDefaultActionSelection");
expect(source).not.toContain("suppressModeActionReset");
});
it("re-derives the shown pair when a capability comes or goes, instead of degrading one way", () => {
// A capability limit belongs to `effective`, so it has to be recomputed from
// the conversation's recording when the environment changes. The one-way
// downgrades that used to live here stranded the panel on the degraded value:
// `send()` reads the shown mode, so a connection that merely blipped kept an
// Agent conversation sending as Ask.
expect(bodyAt("function reapplyPickerCapabilities()")).toContain("resolveConversationPicker(pickerRecordFor(conversation), pickerAmbient()).effective");
expect(bodyAt("function reapplyPickerCapabilities()")).toContain("applyCapabilities(pending, pickerAmbient())");
// The capability watch is `immediate`, so it is declared below the
// per-conversation key maps it reads (a TDZ otherwise).
expect(source).toContain("watch([pluginContext, boundConnection], () => reapplyPickerCapabilities(), { immediate: true });");
// Nothing writes the pair outside the controller — not even a capability.
expect(source).not.toMatch(/\bassistantMode\.value = /);
expect(source).not.toMatch(/\bactiveAction\.value = /);
});
});
@@ -1,195 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Regression coverage for https://github.com/t8y2/dbx/issues/11962.
//
// The AI panel's SQL actions deliver to an editor tab resolved from the
// conversation's bound connection, created without activation (#9902). When the
// tab was not on screen the click looked like a no-op and the database error
// stayed in that background tab. App.vue is a large SFC the suite never mounts,
// so the wiring is pinned as source text (same approach as
// aiRedisConsoleRouting.spec.ts); the decision table itself is unit-tested in
// lib/ai/__tests__/aiSqlActionFeedback.spec.ts.
//
// These assertions read the .vue sources verbatim, so run `oxfmt` on the changed
// files BEFORE running this spec — a reflow of the pinned text fails it.
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
function appBodyOf(fnSignature: string): string {
const start = appSource.indexOf(fnSignature);
expect(start, `expected to find "${fnSignature}" in App.vue`).toBeGreaterThanOrEqual(0);
const braceStart = appSource.indexOf("{", start);
let depth = 0;
for (let i = braceStart; i < appSource.length; i++) {
if (appSource[i] === "{") depth++;
else if (appSource[i] === "}") {
depth--;
if (depth === 0) return appSource.slice(braceStart, i + 1);
}
}
throw new Error(`unbalanced braces reading body of "${fnSignature}"`);
}
describe("AI SQL action feedback (#11962)", () => {
it("treats a target tab as visible only when the workspace renders and some group shows it", () => {
const onScreen = appBodyOf("function aiTargetTabOnScreen(tabId: string): boolean");
expect(onScreen).toContain("workspaceRendered()");
// Same idiom as SqlEditorWorkspace.editorGroupForTab: every group renders, so
// a non-focused split group's active tab counts as visible.
expect(onScreen).toContain("queryStore.groups.some((group) => group.activeTabId === tabId)");
const rendered = appBodyOf("function workspaceRendered(): boolean");
// Mirrors the v-show on [data-editor-content]: a maximized AI panel hides the
// workspace, so nothing in it can deliver feedback.
expect(rendered).toContain("!isAiPanelMaximized.value");
expect(rendered).toContain("!isHistoryPanelMaximized.value");
expect(rendered).toContain("!isSpecialPageActive.value");
expect(rendered).not.toContain("isZenMode");
});
it("routes the feedback decision through the pure mapper and only reveals on click", () => {
const settle = appBodyOf("function settleAiSqlAction(requestId: number | undefined, outcome: AiSqlActionOutcome): void");
expect(settle).toContain("aiSqlActionFeedback(outcome, { targetOnScreen");
expect(settle).toContain('if (feedback.level === "none" || feedbackText === undefined) return;');
// The toast's action button is the ONLY activation site on this path.
expect(settle).toContain("onClick: () => activateQueryTab(revealTabId)");
expect(settle).not.toContain("activateQueryTab(tabId)");
expect(settle).toContain("aiAssistantRef.value.reportAiSqlAction(report);");
// The summary shown in the panel comes from the target tab's own result.
expect(settle).toContain("aiTargetResultCounts(outcome.tabId)");
// A request that never started still says why inside the block, where the text
// outlives the toast.
expect(settle).toContain('{ requestId, status: "unavailable" as const, ...(feedbackText ? { message: feedbackText } : {}) }');
});
it("reports dismissal instead of re-toasting a dialog the user already answered", () => {
const reasons = appSource.slice(appSource.indexOf("const DISMISSED_OUTCOME_REASONS"), appSource.indexOf("const DISMISSED_OUTCOME_REASONS") + 200);
for (const reason of ["cancelled", "parameters", "blocked", "missing-database"]) expect(reasons).toContain(`"${reason}"`);
});
it("leaves no silent early return on the three AI SQL action paths", () => {
for (const handler of ["function onAiAppendSql(", "function onAiExecuteSql("]) {
const body = appBodyOf(handler);
// Every `!tabId` branch settles with a reportable reason...
expect(body).toContain('settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" });');
expect(body).not.toMatch(/if \(!tabId\) return;/);
}
// ...including the temp-run path, which has no branch of its own and hands
// the empty tab id to the shared helper.
expect(appBodyOf("function runAiGeneratedSql(")).toContain("if (!tabId) {");
expect(appBodyOf("function runAiGeneratedSql(")).toContain('settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" });');
expect(appBodyOf("function runAiGeneratedSql(")).toContain("await tryExecute(sql, { tabId })");
expect(appBodyOf("function onAiTempRunSql(")).toContain("void runAiGeneratedSql(sql, ensureQueryTabForConnection(target), requestId);");
});
it("keeps #9902 intact: the AI action itself never moves the workspace", () => {
// Target resolution is unchanged (connection + database + exact schema).
const target = appBodyOf("function ensureQueryTabForConnection(target: AiConversationBinding): string");
expect(target).toContain("{ activate: false }");
// No AI action handler activates, selects or creates a tab behind the user's back.
for (const handler of ["function onAiAppendSql(", "function onAiExecuteSql(", "function onAiTempRunSql(", "function runAiGeneratedSql("]) {
const body = appBodyOf(handler);
expect(body).not.toContain("activateTab");
expect(body).not.toContain("switchTab");
}
});
it("passes the request id through every AI action emit", () => {
expect(appSource).toContain('@append-sql="onAiAppendSql"');
expect(appSource).toContain('@execute-sql="onAiExecuteSql"');
expect(appSource).toContain('@temp-run-sql="onAiTempRunSql"');
// Every AI action emit forwards the request id as a third argument.
const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
expect(assistant).toContain('emit("appendSql", code, conversationBinding.value, requestId);');
expect(assistant).toContain('emit("executeSql", code, conversationBinding.value, requestId);');
expect(assistant).toContain('emit("tempRunSql", code, conversationBinding.value, requestId);');
// The auto-execute path is deliberately excluded: it already reports in-chat.
expect(appSource).toContain('@request-auto-execute-sql="onAiRequestAutoExecuteSql"');
expect(appBodyOf("function onAiRequestAutoExecuteSql(")).not.toContain("settleAiSqlAction");
});
it("declares the panel report method on the handle type and the component", () => {
expect(appSource).toContain("reportAiSqlAction: (report: AiSqlActionReport) => void;");
const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
expect(assistant).toContain("function reportAiSqlAction(report: AiSqlActionReport)");
expect(assistant).toContain("defineExpose({ openPluginConversation, openExternalContext, triggerAction, clearContextReferences, selectConversationById, focusSearch, reportAiSqlAction });");
});
it("drops positional block statuses whenever the transcript is replaced", () => {
const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
// Block keys are positional (`code:<message>:<segment>`), so a status left
// behind by a wiped transcript would render as the outcome of whatever block
// now sits at that index — another conversation's error, or the regenerated
// message of an edit-resend.
expect(assistant.match(/function clearSqlActionBlockStatuses\(/g)).toHaveLength(1);
for (const replaced of [
"messages.value = messages.value.slice(0, actualIndex);", // edit-resend truncation
"messages.value = [];", // clearMessages (new chat)
"messages.value = activeRun?.messages ?? chatMessagesFromConversation(conv);", // selectConversation
]) {
const at = assistant.indexOf(replaced);
expect(at, `expected to find "${replaced}" in AiAssistant.vue`).toBeGreaterThanOrEqual(0);
expect(assistant.slice(at, at + 300)).toContain("clearSqlActionBlockStatuses();");
}
});
it("announces the block outcome through a status live region", () => {
const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
// The outcome lands in the transcript, not in the tab the user is looking at,
// so it has to be announced. `role="status"` matches the other block-level
// notices in this file; the row stays mounted between running and terminal, so
// the change is a text mutation inside an existing live region.
const row = assistant.slice(assistant.indexOf("data-ai-sql-action-status"));
expect(row.slice(0, 200)).toContain('role="status"');
});
it("lets the newest click on a block own its status", () => {
const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
// Clicking the same block twice used to let the FIRST response settle the
// block (last write wins by arrival order, not by click order).
expect(assistant).toContain('next.set(key, { phase: "running", requestId });');
expect(assistant).toContain('if (pending?.phase === "running" && pending.requestId !== report.requestId)');
});
it("carries the AI request across a dialog-parked execution", () => {
// A parameter / danger / Redis dialog makes tryExecute return `deferred`, so the
// settled execution has to be correlated back to the click: App remembers the
// request per tab and settles from the resumed execution.
const settle = appBodyOf("function settleAiSqlAction(requestId: number | undefined, outcome: AiSqlActionOutcome): void");
expect(settle).toContain('if (outcome.status === "deferred") {');
expect(settle).toContain("rememberDeferredAiSqlAction(outcome.tabId, requestId);");
const deferredSettle = appBodyOf("function settleDeferredAiSqlAction(outcome: SqlExecutionOutcome): void");
expect(deferredSettle).toContain("deferredAiSqlActions.get(tabId)");
expect(deferredSettle).toContain("if (requestId === undefined) return;");
expect(deferredSettle).toContain("settleAiSqlAction(requestId, outcome);");
// Editor-initiated executions reach the same callback and must not be toasted.
expect(deferredSettle).toContain("deferredAiSqlActions.delete(tabId);");
// The composable reports exactly where the dialog's own handlers discarded the
// outcome before (#11962 review).
const execution = readFileSync(new URL("../../../composables/useSqlExecution.ts", import.meta.url), "utf8");
expect(execution).toContain("reportDeferredExecution(await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: tab.id }));");
expect(execution).toContain("reportDeferredExecution(await continueExecute(context, sql, sourceOffset,");
expect(execution).toContain('return deferred("parameters", context.tabId);');
expect(execution).toContain('return deferred("danger", context.tabId);');
expect(execution).toContain('return deferred("redis-confirm", context.tabId);');
// Cancelling a dialog unbinds the parked request instead of leaving it waiting.
expect(execution).toContain('if (abandonedTabId) reportDeferredExecution(notStarted("cancelled", abandonedTabId));');
});
it("names the target tab in the off-screen toast of every locale", () => {
// The toast is the only thing that tells the user *where* an off-screen run
// landed, so these keys must interpolate the tab title — and the block copy
// must not (it renders under the SQL, with no tab name to interpolate).
const locales = ["az", "en", "es", "id", "it", "ja", "ko", "pt-BR", "ru", "th", "tr", "zh-CN", "zh-TW"];
for (const locale of locales) {
const messages = readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8");
expect(messages, locale).toMatch(/sqlActionSucceededIn: "[^"]*\{title\}[^"]*"/);
expect(messages, locale).toMatch(/sqlActionAppliedIn: "[^"]*\{title\}[^"]*"/);
expect(messages, locale).toMatch(/sqlActionFailed: "[^"]*\{title\}[^"]*\{message\}[^"]*"/);
expect(messages, locale).toMatch(/sqlActionSucceeded: "[^"{]*"/);
}
});
});
@@ -1,20 +0,0 @@
import { describe, expect, it } from "vitest";
import assistantSource from "../AiAssistant.vue?raw";
describe("AiAssistant typography scope", () => {
it("uses one scoped content wrapper for streaming and completed Markdown", () => {
expect(assistantSource).toContain("messageRenderer.value.render(msg.content, { streaming: isStreamingMessage(msg) })");
expect(assistantSource).toContain('data-ai-assistant-message-content class="ai-conversation-text');
expect(assistantSource).toContain('data-ai-user-message-content class="whitespace-pre-wrap"');
});
it("scopes typography to message and input text while preserving monospace code", () => {
expect(assistantSource).toContain('data-ai-assistant-root class="flex h-full min-h-0 flex-col overflow-hidden" :style="aiTypographyStyle"');
expect(assistantSource).toContain('data-edit-textarea\n v-model="editingContent"');
expect(assistantSource).toContain('class="ai-conversation-text w-full resize-none rounded-lg');
expect(assistantSource).toContain('class="ai-conversation-text w-full resize-none bg-transparent');
expect(assistantSource).toMatch(/\.ai-markdown :deep\(code\)[\s\S]*font-family: ui-monospace, monospace;/);
expect(assistantSource).toMatch(/\.ai-code-block \{[\s\S]*font-family: ui-monospace, monospace;/);
expect(assistantSource).not.toContain("document.body.style.fontFamily");
});
});
@@ -1,47 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
const localePaths = ["az", "en", "es", "id", "it", "ja", "ko", "pt-BR", "ru", "tr", "zh-CN", "zh-TW"];
describe("AI assistant write confirmation replacement", () => {
it("renders a localized confirmation from the backend's semantic event", () => {
expect(source).toContain("function replaceAssistantText(assistantIdx: number, content: string)");
expect(source).toContain('pendingAssistantDelta = "";');
expect(source).toContain("msg.content = content;");
expect(source).toContain("function writeSqlConfirmationText(sql: string)");
expect(source).toContain('event.type === "write_sql_confirmation_required"');
expect(source).toContain("replaceAssistantText(assistantIdx, writeSqlConfirmationText(event.sql));");
expect(source).toContain('event.type === "production_write_blocked"');
});
it("does not render an executable write confirmation without exact SQL", () => {
expect(source).toContain("looksLikeWriteSqlProposal(msg.content) && !isActionableWriteSqlProposal(msg.content)");
expect(source).toContain('if (positive && assistantMode.value === "agent" && isWriteConfirmation)');
});
it("routes generic action replies separately from write-SQL replies", () => {
expect(source).toContain('isWriteConfirmation ? t("ai.writeSqlConfirmationReplyYes") : t("ai.proposalConfirmReplyYes")');
expect(source).toContain('isWriteConfirmation ? t("ai.writeSqlConfirmationReplyNo") : t("ai.proposalConfirmReplyNo")');
});
it("marks backend-generated confirmations as structurally actionable", () => {
expect(source).toContain('msg.kind = "writeSqlConfirmation";');
expect(source).toContain('msg.kind = "productionWriteBlocked";');
expect(source).toContain('if (msg.kind === "writeSqlConfirmation") return extractSingleSqlCodeBlock(msg.content) ? msg : null;');
expect(source).toContain("isActionableWriteProposalMessage(msg)");
});
it("provides the write-confirmation wording in every supported locale", () => {
for (const locale of localePaths) {
const messages = readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8");
expect(messages).toContain("writeSqlConfirmationRequired:");
expect(messages).toContain("writeSqlConfirmationQuestion:");
expect(messages).toContain("writeSqlConfirmYes:");
expect(messages).toContain("writeSqlConfirmNo:");
expect(messages).toContain("productionWriteBlocked:");
expect(messages).toContain("proposalConfirmReplyYes:");
expect(messages).toContain("proposalConfirmReplyNo:");
}
});
});
@@ -1,11 +1,6 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { createQueryEditorSearchKeymap, replaceFallbackKey } from "@/lib/editor/queryEditorSearchKeymap";
const editorSearchPanelSource = readFileSync(new URL("../EditorSearchPanel.vue", import.meta.url), "utf8");
const queryEditorSource = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8");
const contentAreaSource = readFileSync(new URL("../../layout/ContentArea.vue", import.meta.url), "utf8");
describe("QueryEditor search shortcuts", () => {
it("opens search and replace in editable editors", () => {
const openSearch = vi.fn(() => true);
@@ -63,16 +58,4 @@ describe("QueryEditor search shortcuts", () => {
expect(openSearch).toHaveBeenCalledOnce();
expect(openReplace).not.toHaveBeenCalled();
});
it("keeps the existing search navigation and read-only replace guards", () => {
expect(queryEditorSource).toMatch(/\.\.\.binding\(shortcuts\.sendSelectionToAi[\s\S]*\.\.\.createQueryEditorSearchKeymap/);
expect(queryEditorSource).not.toMatch(/Prec\.highest\(\s*keymap\.of\(\[\s*\.\.\.createQueryEditorSearchKeymap/);
expect(queryEditorSource).toMatch(/function openReplace\(\): boolean \{\s*if \(props\.readOnly\) return false;/);
expect(contentAreaSource).toContain("if (props.resultOnly) return dataGridRef.value?.focusSearch(target) ?? false;");
expect(contentAreaSource).toContain("return queryEditorRef.value?.openSearch() ?? false;");
expect(contentAreaSource).toContain("return queryEditorRef.value?.openReplace() ?? false;");
expect(queryEditorSource).toMatch(/key:\s*"Escape"/);
expect(editorSearchPanelSource).toContain('e.key === "Enter" && !e.shiftKey');
expect(editorSearchPanelSource).toContain('e.key === "Enter" && e.shiftKey');
});
});
@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { SETTINGS_SEARCH_DEFINITIONS } from "@/lib/settings/settingsSearch";
import dialogSource from "../EditorSettingsDialog.vue?raw";
describe("EditorSettingsDialog AI typography settings", () => {
it("exposes the family and size controls through settings search", () => {
const definitions = SETTINGS_SEARCH_DEFINITIONS.filter((definition) => definition.id.startsWith("ai-conversation-font-"));
expect(definitions).toEqual([expect.objectContaining({ id: "ai-conversation-font-family", category: "ai", targetId: "ai-typography" }), expect.objectContaining({ id: "ai-conversation-font-size", category: "ai", targetId: "ai-typography" })]);
expect(dialogSource).toContain('data-settings-search-id="ai-typography"');
expect(dialogSource).toContain('id="ai-conversation-font-size"');
});
it("persists each control immediately and offers a dedicated reset", () => {
expect(dialogSource).toContain('@update:model-value="onAiFontFamilyChange"');
expect(dialogSource).toContain('@change="commitAiFontSize"');
expect(dialogSource).toContain('@click="resetAiTypography"');
expect(dialogSource).toContain("settingsStore.updateEditorSettingsAndPersist(partial)");
});
it("keeps system and custom conversation fonts independent from editor fallbacks", () => {
expect(dialogSource).toContain("systemFonts.value.map(aiConversationFontFamilyForName)");
expect(dialogSource).toContain(':normalize-custom="normalizeAiConversationFontFamilyInput"');
});
});
@@ -1,36 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
describe("EditorSettingsDialog CC-SWITCH provider", () => {
it("gates the provider on an installed compatible plugin capability", () => {
expect(dialogSource).toContain('<SelectItem v-if="!isWeb && aiCcSwitchPluginAvailable" :value="CC_SWITCH_PROVIDER_ID">');
expect(dialogSource).toContain("const aiCcSwitchPluginAvailable = computed(() => isCcSwitchPluginAvailable(aiCcSwitchPlugins.value));");
expect(dialogSource).toContain("listPlugins");
});
it("keeps only the host import action", () => {
expect(dialogSource).toContain('@click="aiImportCcSwitchConfigs"');
expect(dialogSource).not.toContain("aiInstallCcSwitchPlugin");
expect(dialogSource).not.toContain("aiInstallCcSwitchPluginLocal");
expect(dialogSource).not.toContain("aiUninstallCcSwitchPlugin");
expect(dialogSource).not.toContain("installCcSwitchPlugin");
expect(dialogSource).not.toContain("uninstallCcSwitchPlugin");
});
it("offers the plugin center when the capability is unavailable", () => {
expect(dialogSource).toContain("!aiCcSwitchPluginAvailable");
expect(dialogSource).toContain('aiCcSwitchPluginStatus.value === "incompatible" ? "ai.ccSwitchPluginIncompatible" : "ai.ccSwitchPluginNotInstalledStatus"');
expect(dialogSource).toContain("t(aiCcSwitchPluginStatusText)");
expect(dialogSource).toContain(`@click="emit('open-cc-switch-plugin-center')"`);
});
it("lists unsupported source entries as disabled with a reason and can refresh the source", () => {
expect(dialogSource).toContain('@click="refreshAiImportSources"');
expect(dialogSource).toContain('v-for="(skipped, index) in aiImportSkipped"');
expect(dialogSource).toContain(":aria-label=\"t('ai.aiImportUnsupported')\"");
expect(dialogSource).toContain("aiImportSkippedReason(skipped.reason)");
expect(dialogSource).toContain("ccSwitchUnsupportedProviderType");
});
});
@@ -4,7 +4,6 @@ import { createApp, defineComponent, h, nextTick, ref } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import ThemeCustomizerDialog from "../ThemeCustomizerDialog.vue";
import dialogSource from "../EditorSettingsDialog.vue?raw";
vi.mock("vue-i18n", async () => {
const { ref } = await import("vue");
@@ -63,14 +62,3 @@ describe("ThemeCustomizerDialog with empty themes (#10994)", () => {
expect(document.body.innerHTML).toContain("Custom");
});
});
describe("EditorSettingsDialog theme select fallback (#10994)", () => {
it("includes a custom theme option even if editCustomThemes is empty", () => {
// Ensures themeSelectOptions checks editCustomThemes.value.length === 0
// and supplies a fallback custom theme entry so the entry point never disappears
expect(dialogSource).toContain("editCustomThemes.value.length === 0");
expect(dialogSource).toContain('value: "custom"');
expect(dialogSource).toContain('} else if (v === "custom")');
expect(dialogSource).toContain("editCustomThemes.value = JSON.parse(JSON.stringify(DEFAULT_CUSTOM_THEMES))");
});
});
@@ -1,42 +0,0 @@
// Guards the settings-search "reveal" contract for the debug logging switch
// (#10308): searching 日志 and picking the result must land on the tab that
// actually renders the switch, because the dialog switches tabs from the search
// entry's category. The entry used to declare "appearance" while the control
// lives in the About section, so the highlight never appeared. Checked against
// the real dialog source instead of mounting the multi-thousand-line dialog.
import { describe, expect, it } from "vitest";
import { SETTINGS_SEARCH_DEFINITIONS } from "@/lib/settings/settingsSearch";
import dialogSource from "../EditorSettingsDialog.vue?raw";
const DEBUG_LOGGING_SWITCH_ID = 'id="debug-logging-enabled"';
function tabRenderingSnippet(snippet: string, marker: string): string | null {
const sectionPattern = /activeSettingsTab === '([a-z-]+)'/g;
const sections: { tab: string; index: number }[] = [];
let match: RegExpExecArray | null;
while ((match = sectionPattern.exec(snippet))) sections.push({ tab: match[1], index: match.index });
const markerIndex = snippet.indexOf(marker);
if (markerIndex < 0) return null;
let owningTab: string | null = null;
for (const section of sections) {
if (section.index < markerIndex) owningTab = section.tab;
}
return owningTab;
}
describe("EditorSettingsDialog settings search targets", () => {
it("renders the debug logging switch in the tab its search entry points at", () => {
const owningTab = tabRenderingSnippet(dialogSource, DEBUG_LOGGING_SWITCH_ID);
expect(owningTab).toBe("about");
const entry = SETTINGS_SEARCH_DEFINITIONS.find((definition) => definition.id === "about-debug-logs");
expect(entry?.category).toBe(owningTab);
expect(dialogSource).toContain(`data-settings-search-id="${entry?.targetId}"`);
});
it("renders the snippet trigger key setting in the tab its search entry points at", () => {
const entry = SETTINGS_SEARCH_DEFINITIONS.find((definition) => definition.id === "snippet-trigger-key");
expect(entry?.category).toBe("snippets");
expect(dialogSource).toContain(`data-settings-search-id="${entry?.targetId}"`);
});
});
@@ -1,55 +0,0 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const dataGridSource = readFileSync(resolve(import.meta.dirname, "../DataGrid.vue"), "utf8");
const editorSettingsDialogSource = readFileSync(resolve(import.meta.dirname, "../../editor/EditorSettingsDialog.vue"), "utf8");
describe("DataGrid cell detail dialog default preference (#2303)", () => {
it("binds cellDetailDialogDefault to settingsStore.editorSettings.dataGridCellDetailDialogDefault", () => {
expect(dataGridSource).toMatch(/const cellDetailDialogDefault = computed\(\(\) => settingsStore\.editorSettings\.dataGridCellDetailDialogDefault\);/);
});
it("opens cell detail dialog directly when cellDetailDialogDefault is enabled in showCellDetailsForVisibleCell", () => {
expect(dataGridSource).toContain(
"function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number, actualColIdx: number) {\n" +
" clearRowSelection();\n" +
" invalidateContextMenuTarget();\n" +
" selectSingleCell(rowIndex, visibleColIdx);\n" +
" if (cellDetailDialogDefault.value) {\n" +
" showCellDetail.value = false;\n" +
" openCellDetailDialog(rowIndex, actualColIdx);\n" +
" } else {\n" +
" showCellDetails(rowIndex, actualColIdx);\n" +
" }\n" +
"}",
);
});
it("opens cell detail dialog directly when cellDetailDialogDefault is enabled in showTransposeCellDetails", () => {
expect(dataGridSource).toContain(
"function showTransposeCellDetails(rowIndex: number, actualColIdx: number) {\n" +
" const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx);\n" +
" if (visibleColIdx < 0) return;\n" +
" clearRowSelection();\n" +
" invalidateContextMenuTarget();\n" +
" selectSingleCell(rowIndex, visibleColIdx);\n" +
" transposeRowIndex.value = rowIndex;\n" +
" if (cellDetailDialogDefault.value) {\n" +
" showCellDetail.value = false;\n" +
" openCellDetailDialog(rowIndex, actualColIdx);\n" +
" } else {\n" +
" showCellDetails(rowIndex, actualColIdx);\n" +
" }\n" +
" gridRef.value?.focus({ preventScroll: true });\n" +
"}",
);
});
it("renders the dataGridCellDetailDialogDefault switch in EditorSettingsDialog", () => {
expect(editorSettingsDialogSource).toContain('id="data-grid-cell-detail-dialog-default"');
expect(editorSettingsDialogSource).toContain('v-model="editDataGridCellDetailDialogDefault"');
expect(editorSettingsDialogSource).toContain('t("settings.dataGridCellDetailDialogDefault")');
expect(editorSettingsDialogSource).toContain('t("settings.dataGridCellDetailDialogDefaultDescription")');
});
});
@@ -1,34 +1,11 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { foldCode, unfoldCode } from "@codemirror/language";
import { useCellDetailEditor } from "@/composables/useCellDetailEditor";
const dataGridSource = readFileSync(resolve(import.meta.dirname, "../DataGrid.vue"), "utf8");
const useDataGridCellDetailSource = readFileSync(resolve(import.meta.dirname, "../../../composables/useDataGridCellDetail.ts"), "utf8");
const cellDetailDialogSource = readFileSync(resolve(import.meta.dirname, "../DataGridCellDetailDialog.vue"), "utf8");
const mongoJsonPreviewSource = readFileSync(resolve(import.meta.dirname, "../DataGridMongoJsonPreview.vue"), "utf8");
describe("DataGrid cell detail code folding and line numbers (#10431)", () => {
it("enables code folding and line numbers across all cell detail and JSON preview editors", () => {
// #10431: useDataGridCellDetail, DataGrid valueDetailEditor, DataGridCellDetailDialog,
// and DataGridMongoJsonPreview must enable folding and line numbers for JSON values
expect(useDataGridCellDetailSource).toMatch(/folding:\s*true/);
expect(useDataGridCellDetailSource).toMatch(/lineNumbers:\s*true/);
expect(dataGridSource).toMatch(/folding:\s*true/);
expect(dataGridSource).toMatch(/lineNumbers:\s*true/);
expect(cellDetailDialogSource).toMatch(/folding:\s*true/);
expect(cellDetailDialogSource).toMatch(/lineNumbers:\s*true/);
expect(mongoJsonPreviewSource).toMatch(/folding:\s*true/);
expect(mongoJsonPreviewSource).toMatch(/lineNumbers:\s*true/);
});
it("mounts line numbers and fold gutter in useCellDetailEditor when folding and lineNumbers are enabled", async () => {
const pinia = createPinia();
setActivePinia(pinia);
@@ -1,7 +1,5 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
@@ -14,10 +12,6 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useCellDetailEditor } from "@/composables/useCellDetailEditor";
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
const dataGridSource = readFileSync(resolve(import.meta.dirname, "../DataGrid.vue"), "utf8");
const useDataGridCellDetailSource = readFileSync(resolve(import.meta.dirname, "../../../composables/useDataGridCellDetail.ts"), "utf8");
const cellDetailDialogSource = readFileSync(resolve(import.meta.dirname, "../DataGridCellDetailDialog.vue"), "utf8");
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
const i18n = createI18n({
@@ -86,14 +80,6 @@ afterEach(() => {
});
describe("DataGrid cell editor and value editor word wrap (#10506)", () => {
it("binds lineWrapping in useDataGridCellDetail, DataGrid valueDetailEditor, and cell detail dialog", () => {
// #10506: useDataGridCellDetail, DataGrid.vue (valueDetailEditor), and DataGridCellDetailDialog
// must connect CodeMirror lineWrapping to settingsStore.editorSettings.wordWrap
expect(useDataGridCellDetailSource).toMatch(/lineWrapping:\s*\(\)\s*=>\s*settingsStore\.editorSettings\.wordWrap/);
expect(dataGridSource).toMatch(/lineWrapping:\s*\(\)\s*=>\s*settingsStore\.editorSettings\.wordWrap/);
expect(cellDetailDialogSource).toMatch(/lineWrapping:\s*\(\)\s*=>\s*settingsStore\.editorSettings\.wordWrap/);
});
it("dynamically toggles CodeMirror cm-lineWrapping in useCellDetailEditor based on wordWrap setting", async () => {
const settingsStore = useSettingsStore();
settingsStore.editorSettings.wordWrap = false;
@@ -1,39 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
const largeValueSource = readFileSync(new URL("../../../composables/useDataGridLargeValues.ts", import.meta.url), "utf8");
describe("DataGrid delete row confirmation details", () => {
it("still wires deleteRowDetails into the delete-row DangerConfirmDialog", () => {
const dialogStart = dataGridSource.indexOf('v-model:open="showDeleteRowConfirm"');
const dialogEnd = dataGridSource.indexOf('@confirm="confirmDeleteRow"', dialogStart);
expect(dialogStart).toBeGreaterThanOrEqual(0);
expect(dialogEnd).toBeGreaterThan(dialogStart);
const dialogSource = dataGridSource.slice(dialogStart, dialogEnd);
expect(dialogSource).toContain(':details="deleteRowDetails"');
});
it("uses the unbounded confirmation formatter for delete details", () => {
const detailsStart = dataGridSource.indexOf("const deleteRowDetails = computed");
const detailsEnd = dataGridSource.indexOf("const hasVisibleRows", detailsStart);
expect(detailsStart).toBeGreaterThanOrEqual(0);
expect(detailsEnd).toBeGreaterThan(detailsStart);
expect(dataGridSource.slice(detailsStart, detailsEnd)).toContain("formatGridItemCellForConfirmation");
expect(largeValueSource).toContain("options.formatCell(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex), false)");
});
it("keeps close-on-confirm disabled so confirmDeleteRow runs before the dialog auto-closes", () => {
const dialogStart = dataGridSource.indexOf('v-model:open="showDeleteRowConfirm"');
const dialogEnd = dataGridSource.indexOf('@confirm="confirmDeleteRow"', dialogStart);
expect(dialogStart).toBeGreaterThanOrEqual(0);
expect(dialogEnd).toBeGreaterThan(dialogStart);
const dialogSource = dataGridSource.slice(dialogStart, dialogEnd);
expect(dialogSource).toContain(':close-on-confirm="false"');
});
});
@@ -1,35 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Fixes #10095: the vertical/horizontal scrollbar overlays are absolutely
// positioned relative to the nearest ancestor with `position: relative`.
// Before this fix that ancestor was the grid's *outer* wrapper, which starts
// at the sticky column-header row — so `top: 10px` on the vertical scrollbar
// (and the horizontal scrollbar's `bottom: 0`) were measured from the header,
// landing the scrollbar visually on top of the header instead of below it.
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
describe("DataGrid scrollbar positioning", () => {
it("wraps the header-less scroll body in its own relative stacking context", () => {
const headerEnd = dataGridSource.indexOf('<div v-if="gridScrollbarGutter > 0" class="shrink-0 border-l border-border w-(--grid-scrollbar-gutter)" />\n </div>\n </div>');
expect(headerEnd).toBeGreaterThanOrEqual(0);
const wrapperStart = dataGridSource.indexOf('<div class="relative min-h-0 flex-1 flex flex-col overflow-hidden">', headerEnd);
expect(wrapperStart).toBeGreaterThan(headerEnd);
// The vertical scrollbar (and its horizontal sibling) must be inside the
// new wrapper, not siblings of the header at the outer level. Anchor on
// the "Table Info Drawer" comment that follows the wrapper's own closing
// tags — an inner RecycleScroller slot's `</template>` closes earlier and
// is not a safe upper bound.
const verticalScrollbarIndex = dataGridSource.indexOf('ref="gridVerticalScrollbarTrackRef"', wrapperStart);
const drawerCommentIndex = dataGridSource.indexOf("<!-- Table Info Drawer -->", wrapperStart);
expect(verticalScrollbarIndex).toBeGreaterThan(wrapperStart);
expect(verticalScrollbarIndex).toBeLessThan(drawerCommentIndex);
});
it("closes the wrapper before the sticky-header template ends, leaving markup balanced", () => {
const wrapperOpenCount = (dataGridSource.match(/<div class="relative min-h-0 flex-1 flex flex-col overflow-hidden">/g) || []).length;
expect(wrapperOpenCount).toBe(1);
});
});
@@ -1,6 +1,5 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { createApp, defineComponent, h, nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
@@ -114,18 +113,6 @@ import DataGridSortBuilder from "@/components/grid/DataGridSortBuilder.vue";
import DataGridSortWorkbench from "@/components/grid/DataGridSortWorkbench.vue";
import DataGridTextSortWorkbench from "@/components/grid/DataGridTextSortWorkbench.vue";
const cellDetailPanelSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailPanel.vue", "utf8");
const cellDetailDialogSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailDialog.vue", "utf8");
const binaryTextPreviewSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailTextPreview.vue", "utf8");
const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
describe("DataGrid opaque aggregate-state safety", () => {
it("blocks cell edits and keyless row deletion through the centralized gates", () => {
expect(dataGridSource).toMatch(/function canEditCellItem[\s\S]*isOpaqueAggregateStateColumnType\(allColumnTypes\.value\[columnIndex\]\)/);
expect(dataGridSource).toMatch(/function canDeleteRowItem[\s\S]*canUseKeylessRowPredicate[\s\S]*hasUnsafeOpaqueAggregateStatePredicate\(allColumnTypes\.value, item\.data\)/);
});
});
function detail(patch: Partial<DataGridCellDetail> = {}): DataGridCellDetail {
return {
rowNumber: 1,
@@ -528,13 +515,6 @@ describe("DataGridPagination", () => {
});
});
describe("DataGrid selection summary copy wiring", () => {
it("routes the pagination aggregate copy through the grid clipboard helper", () => {
expect(dataGridSource).toMatch(/@copy-selection-summary="copyText"/);
expect(dataGridSource).toMatch(/:selection-summary-pending="isSelectingCells"/);
});
});
describe("DataGridColumnHeader", () => {
it("uses a compact formatter marker with an accessible explanation", () => {
const mounted = mountComponent(DataGridColumnHeader, {
@@ -2336,16 +2316,6 @@ describe("cell detail surfaces", () => {
expect(findOne(panel.root, (node) => node.type === "textarea").value).toBe("Hello");
});
it("keeps the binary text preview component strictly read-only and shared by both surfaces", () => {
expect(binaryTextPreviewSource).toContain("binaryCellTextPreview(props.value");
// 不发出任何编辑/提交事件,也不给 detail.value / rawValue 赋值。
expect(binaryTextPreviewSource).not.toContain("defineEmits");
expect(binaryTextPreviewSource).not.toMatch(/(?:props|detail)\.(?:value|rawValue)\s*=/);
expect(cellDetailDialogSource).toMatch(/<DataGridCellDetailTextPreview\s+v-if="open && isBinaryCellColumnType\(detail\.type\)"/);
// Panel 与下载菜单同一道闸门:编辑中不对草稿做只读文本预览。
expect(cellDetailPanelSource).toMatch(/<DataGridCellDetailTextPreview\s+v-if="!editing && isBinaryCellColumnType\(detail\.type\)"/);
});
it("copies the presented value, emits edit, closes, and replaces the JSON result", async () => {
const copyText = vi.fn();
const edit = vi.fn();
@@ -1,16 +1,11 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import TemporalCellEditor from "../TemporalCellEditor.vue";
const dataGridSource = readFileSync(resolve(import.meta.dirname, "../DataGrid.vue"), "utf8");
const cellDetailPanelSource = readFileSync(resolve(import.meta.dirname, "../DataGridCellDetailPanel.vue"), "utf8");
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
function mountEditor() {
@@ -99,16 +94,4 @@ describe("TemporalCellEditor blur commit (value editor, #10667)", () => {
blurInput(input);
expect(onCommit).toHaveBeenCalledTimes(2);
});
it("keeps the cell details panel temporal editor explicit-commit by design (#10667)", () => {
// 值编辑器 tab 的 temporal 编辑器与同 tab 的 CodeMirror 编辑器(onBlur →
// commitValueEditorEdit)对齐:不再传 commit-on-close="false"。
const valueEditorTemporal = dataGridSource.match(/<TemporalCellEditor[^>]*v-if="detailTemporalEditorConfig"[\s\S]*?\/>/)?.[0] ?? "";
expect(valueEditorTemporal).toContain('@commit="commitValueEditorEdit"');
expect(valueEditorTemporal).not.toContain("commit-on-close");
// 单元格详情 tab 维持显式 确认/取消 交互:其 CodeMirror 同样不失焦提交,
// 且失焦提交会让点「取消」先于 click 触发一次误提交。
expect(cellDetailPanelSource).toContain(':commit-on-close="false"');
});
});
@@ -1,18 +1,11 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import TemporalCellEditor from "../TemporalCellEditor.vue";
const dataGridSource = readFileSync(resolve(import.meta.dirname, "../DataGrid.vue"), "utf8");
const cellDetailPanelSource = readFileSync(resolve(import.meta.dirname, "../DataGridCellDetailPanel.vue"), "utf8");
const dataGridTemporalEditors = dataGridSource.match(/<TemporalCellEditor\b[\s\S]*?\/>/g) ?? [];
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
function mountEditor() {
@@ -91,15 +84,6 @@ describe("TemporalCellEditor save shortcut", () => {
expect(escapeCase.onSave).not.toHaveBeenCalled();
});
it("binds the forwarded save event on every grid temporal editor", () => {
expect(dataGridTemporalEditors.length).toBeGreaterThan(0);
for (const editor of dataGridTemporalEditors) {
expect(editor).toContain('@save="onTemporalCellEditorSave"');
}
expect(cellDetailPanelSource).toContain("@save=\"emit('save')\"");
expect(dataGridSource).toMatch(/onTemporalCellEditorSave\(\)[\s\S]*?saveGridChangesFromShortcut\(\)/);
});
it("re-arms a persistent instance so a value-editor temporal cell can save repeatedly (#10515)", async () => {
// 值编辑器面板用 commitValueEditorEdit 保持 isEditingDetail=true,同一个
// TemporalCellEditor 实例一直挂着;每次 ctrl+s 都必须重新提交,不能被首次提交
@@ -121,20 +105,4 @@ describe("TemporalCellEditor save shortcut", () => {
expect(onSave).toHaveBeenCalledTimes(2);
expect(modelValue.value).toBe("2027-07-08 09:10:11");
});
it("wires onSaveShortcut so the non-temporal value editor can commit + save via Ctrl+S (#10515)", () => {
// useCellDetailEditor(CodeMirror)只认 onSaveShortcut;DataGrid 没传时 varchar 等
// 非 temporal 值编辑器里 ctrl+s 无人消费(必须点「执行」)。这里做结构校验。
expect(dataGridSource).toContain("onSaveShortcut:");
expect(dataGridSource).toMatch(/useCellDetailEditor\(\{[\s\S]{0,2000}?onSaveShortcut[\s\S]{0,1200}?commitValueEditorEdit\(\)/);
// 回归防护:useCellDetailEditor 对每个 keydown 都会回调 onSaveShortcut,处理器
// 必须先用 isSaveShortcut 过滤,否则普通按键也被吞掉,值编辑器完全无法输入。
expect(dataGridSource).toMatch(/onSaveShortcut: \(event\) => \{[\s\S]{0,200}?isSaveShortcut\(event, settingsStore\.editorSettings\.shortcuts\)[\s\S]{0,600}?commitValueEditorEdit\(\)/);
});
it("wires Ctrl+S in the cell details panel editor to commit + save (#10515)", () => {
// 单元格详情 tab 的 CodeMirror 是另一个编辑器实例;ctrl+s 必须与 temporal 详情
// 编辑器的路径一致——先 emit commit(落成待保存变更)再 emit save(触发保存)。
expect(cellDetailPanelSource).toMatch(/onSave: \(\) => \{[\s\S]{0,160}?emit\("commit"\);[\s\S]{0,80}?emit\("save"\);/);
});
});
@@ -1,13 +1,9 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import DataGridAutoRefreshClock from "@/components/grid/DataGridAutoRefreshClock.vue";
import { findOne, mountComponent, type HostNode } from "./vueHostHarness";
const clockPath = "apps/desktop/src/components/grid/DataGridAutoRefreshClock.vue";
const source = readFileSync(clockPath, "utf8");
function findHand(node: HostNode): HostNode {
return findOne(node, (candidate) => candidate.type === "line" && candidate.props["data-auto-refresh-hand"] !== undefined);
}
@@ -58,14 +54,4 @@ describe("DataGridAutoRefreshClock", () => {
host.unmount();
}
});
it("rotates about the clock centre and opts out of motion on request", () => {
expect(source).toContain("transform-box: view-box");
expect(source).toContain("transform-origin: 12px 14px");
expect(source).toContain("animation: data-grid-auto-refresh-sweep var(--dbx-auto-refresh-sweep-duration, 10s) linear");
// House spinner idiom: the motion is cosmetic information, so honour the
// system preference rather than animating unconditionally.
expect(source).toContain("@media (prefers-reduced-motion: reduce)");
expect(source).toContain("animation: none");
});
});
@@ -1,37 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Fixes #10142: In dark mode, native select dropdown menus in TableImportDialog
// (such as column mapping and conflict policy) rendered with a bright white
// popup background and light-colored text in Chromium/WebView2 because native
// select/option elements did not specify color-scheme or popover colors.
const tableImportDialogSource = readFileSync(new URL("../TableImportDialog.vue", import.meta.url), "utf8");
const tokensCssSource = readFileSync(new URL("../../../styles/tokens.css", import.meta.url), "utf8");
const globalsCssSource = readFileSync(new URL("../../../styles/globals.css", import.meta.url), "utf8");
describe("TableImportDialog theme and select color consistency (#10142)", () => {
it("declares color-scheme: light in :root and color-scheme: dark in .dark within tokens.css", () => {
expect(tokensCssSource).toMatch(/:root\s*\{[^}]*color-scheme:\s*light;/);
expect(tokensCssSource).toMatch(/\.dark\s*\{[^}]*color-scheme:\s*dark;/);
});
it("declares select color-scheme and option background in globals.css", () => {
expect(globalsCssSource).toMatch(/select\s*\{[^}]*color-scheme:\s*light;/);
expect(globalsCssSource).toMatch(/\.dark select[^}]*color-scheme:\s*dark;/);
expect(globalsCssSource).toMatch(/select option\s*\{[^}]*background-color:\s*var\(--popover\);[^}]*color:\s*var\(--popover-foreground\);/);
});
it("applies color-scheme and text-foreground to native select controls in TableImportDialog.vue", () => {
expect(tableImportDialogSource).toContain('id="table-import-conflict-policy"');
expect(tableImportDialogSource).toMatch(/id="table-import-conflict-policy"[^>]*\[color-scheme:light\] dark:\[color-scheme:dark\]/);
expect(tableImportDialogSource).toMatch(/id="table-import-conflict-policy"[^>]*text-foreground/);
expect(tableImportDialogSource).toMatch(/class="[^"]*h-7 w-full min-w-0 rounded-md[^"]*\[color-scheme:light\] dark:\[color-scheme:dark\]/);
expect(tableImportDialogSource).toMatch(/class="[^"]*h-7 w-full min-w-0 rounded-md[^"]*text-foreground/);
});
it("applies bg-popover and text-popover-foreground to option elements in TableImportDialog.vue", () => {
expect(tableImportDialogSource).toMatch(/<option[^>]*class="[^"]*bg-popover text-popover-foreground[^"]*"[^>]*>\s*\{\{ t\("tableImport\.skipColumn"\) \}\}\s*<\/option>/);
expect(tableImportDialogSource).toMatch(/<option[^>]*class="[^"]*bg-popover text-popover-foreground[^"]*"[^>]*>\s*\{\{\s*column\.name\s*\}\}\s*<\/option>/);
});
});
@@ -1,32 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const contentAreaSource = readFileSync(new URL("../ContentArea.vue", import.meta.url), "utf8");
function functionSource(name: string, endMarker: string): string {
const start = contentAreaSource.indexOf(`function ${name}(`);
const end = contentAreaSource.indexOf(endMarker, start + 1);
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return contentAreaSource.slice(start, end);
}
describe("ContentArea result-tab view state handoff", () => {
it("dispatches the pre-switch boundary before changing the result index", () => {
const source = functionSource("selectResultItem", "function executionSummaryItemRange(");
expect(source.indexOf("dispatchBeforeResultTabSwitch()")).toBeGreaterThanOrEqual(0);
expect(source.indexOf("dispatchBeforeResultTabSwitch()")).toBeLessThan(source.indexOf("queryStore.setActiveResultIndex"));
});
it("dispatches the pre-switch boundary before changing the result run", () => {
const source = functionSource("selectResultRun", "/**\n * 点击结果标签");
expect(source.indexOf("dispatchBeforeResultTabSwitch()")).toBeGreaterThanOrEqual(0);
expect(source.indexOf("dispatchBeforeResultTabSwitch()")).toBeLessThan(source.indexOf("queryStore.setActiveResultRun"));
});
it("uses the shared before-tab-switch event consumed by DataGrid", () => {
const helper = functionSource("dispatchBeforeResultTabSwitch", "async function selectResultRunFromTab");
expect(helper).toContain('new CustomEvent("dbx:before-tab-switch"');
expect(helper).toContain("fromTabId: props.activeTab.id");
});
});
@@ -1,7 +1,5 @@
/** @vitest-environment happy-dom */
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createApp, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { NacosConnectionInfo } from "@/types/nacos";
@@ -61,7 +59,6 @@ vi.mock("@/components/nacos/NacosRoleAccessControl.vue", async () => {
import NacosAccessControlConsole from "../NacosAccessControlConsole.vue";
const legacySource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/nacos/NacosAccessControl.vue"), "utf8");
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
async function settle() {
@@ -81,13 +78,6 @@ afterEach(() => {
});
describe("NacosAccessControlConsole", () => {
it("requires exact username confirmation before deleting a user in the legacy workspace", () => {
expect(legacySource).toContain("const userDeleteConfirmed = computed(() => userDeleteConfirmation.value === pendingUserDelete.value?.username)");
expect(legacySource).toContain('@click="openDeleteUser(user)"');
expect(legacySource).toContain('t("nacos.accessDeleteUserConfirmationLabel")');
expect(legacySource).toContain(':disabled="deletingUser || !userDeleteConfirmed"');
});
it("keeps the roles-only workspace visible alongside the permission warning", async () => {
const unsupported = { supported: false, reason: "permissionDenied" as const };
mocks.connectionInfo = {
@@ -1,42 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import en from "@/i18n/locales/en";
const source = readFileSync(new URL("../NacosAdminConsole.vue", import.meta.url), "utf8");
/**
* The lint itself is covered by real CodeMirror integration specs
* (`lib/__tests__/nacos/nacosConfigYamlLint.spec.ts` and
* `packages/app-tests/nacosYamlDiagnostics.test.ts`). These assertions pin the
* wiring that only exists inside this component, which is too large to mount.
*/
describe("NacosAdminConsole YAML diagnostics wiring", () => {
it("keeps syntax validation separate from publishing", () => {
const saveRequest = source.slice(source.indexOf("function requestSaveConfig()"), source.indexOf("async function saveConfig()"));
expect(saveRequest).not.toContain("validateCurrentConfig");
expect(saveRequest).toContain("canRequestConfigSave.value");
expect(saveRequest).toContain("void saveConfig()");
expect(source).toContain('@click="validateCurrentConfig()"');
expect(source).toContain("nacosConfigValidationHasErrors(configValidationDiagnostics.value)");
});
it("localizes every key it references, including the new severity labels", () => {
const referenced = [
"validationErrorTitle",
"validationErrorDescription",
"validationWarningTitle",
"validationWarningDescription",
"validationSeverityError",
"validationSeverityWarning",
"yamlDiagnosticDuplicateKey",
"yamlDiagnosticTabIndent",
"yamlDiagnosticSyntaxError",
"yamlDiagnosticParserWarning",
];
const nacos = en.nacos as Record<string, unknown>;
for (const key of referenced) {
expect(typeof nacos[key], key).toBe("string");
expect((nacos[key] as string).length, key).toBeGreaterThan(0);
}
});
});
@@ -1,24 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../NacosRoleAccessControl.vue", import.meta.url), "utf8");
describe("NacosRoleAccessControl", () => {
it("keeps the selected role identity when opening the delete dialog", () => {
expect(source).toContain("resetRoleForm(selectedRole.value.role)");
});
it("makes the exact role-name confirmation requirement explicit before deletion", () => {
expect(source).toContain("const roleDeleteConfirmed = computed(() => roleForm.confirmation === roleForm.role)");
expect(source).toContain('t("nacos.accessDeleteRoleConfirmationLabel")');
expect(source).toContain('t("nacos.accessDeleteRoleConfirmationHint")');
expect(source).toContain("roleDialog === 'delete' && !roleDeleteConfirmed");
});
it("requires the exact username before deleting any user", () => {
expect(source).toContain("const userDeleteConfirmed = computed(() => userForm.confirmation === userForm.username)");
expect(source).toContain('t("nacos.accessDeleteUserConfirmationLabel")');
expect(source).toContain('t("nacos.accessDeleteUserConfirmationHint")');
expect(source).toContain("userDialog === 'delete' && !userDeleteConfirmed");
});
});
@@ -3,14 +3,8 @@
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import DdlScopeFixture from "./DdlDialog.legacyScope.fixture.vue";
const ddlViewDialogSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/objects/DdlViewDialog.vue"), "utf8");
const ddlTabContentSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/layout/ContentArea.vue"), "utf8");
const editorGroupSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/layout/EditorGroup.vue"), "utf8");
const mountedApps: App[] = [];
afterEach(() => {
@@ -63,35 +57,4 @@ describe("DdlViewDialog legacy fallback selectors", () => {
expect(content!.hasAttribute(scopeId!)).toBe(false);
expect(footer!.hasAttribute(scopeId!)).toBe(true);
});
it("keeps the DDL dialog free of per-dialog legacy fallback rules", () => {
// The dialog content element is rendered through reka-ui's portal Teleport and
// never carries this component's scoped data-v attribute, so per-dialog rules
// are avoided entirely: width comes from the global sm:max-w-190 legacy entry
// and the footer layout from the global dialog-footer rule.
expect(ddlViewDialogSource).not.toContain("dbx-legacy-webview");
expect(ddlViewDialogSource).not.toContain("@media");
expect(ddlViewDialogSource).toContain('class="dbx-ddl-view-dialog flex min-h-0 flex-col overflow-hidden sm:max-w-190"');
expect(ddlViewDialogSource).toContain("ddlDisplayOriginal");
expect(ddlViewDialogSource).toContain("resizeDdlDialog");
});
it("keeps the dialog as the default and offers opening the DDL in a new tab", () => {
expect(ddlViewDialogSource).toContain('import { useQueryStore } from "@/stores/queryStore";');
expect(ddlViewDialogSource).toContain("queryStore.createTab(props.connectionId, props.database");
expect(ddlViewDialogSource).toContain("sourceView: true");
expect(ddlViewDialogSource).toContain('ddlOpenMode === "tab"');
expect(ddlViewDialogSource).toContain("ddlViewer = {");
expect(ddlViewDialogSource).toContain("updateEditorSettings({ ddlOpenMode: value })");
expect(ddlViewDialogSource).toContain('@click="openDdlInNewTab"');
});
it("renders DDL tabs read-only with only refresh and table-data actions", () => {
expect(editorGroupSource).toContain("!activeTab.value.ddlViewer");
expect(ddlTabContentSource).toContain('v-if="activeTab.ddlViewer" class="flex h-10');
expect(ddlTabContentSource).toContain('@click="refreshDdlViewer"');
expect(ddlTabContentSource).toContain('@click="viewDdlTableData"');
expect(ddlTabContentSource).toContain(':read-only="!!activeTab.ddlViewer"');
expect(ddlTabContentSource).toContain(':hide-execution-controls="!!activeTab.ddlViewer"');
});
});
@@ -1,15 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const settingsSources = ["../../editor/EditorSettingsDialog.vue", "../../editor/SqlFormatterSettingsPanel.vue", "../McpAuthorizationStepper.vue", "../McpDatabaseScopePicker.vue", "../McpResourceScopePicker.vue", "../SettingsTransferPanel.vue"].map((path) => ({
path,
source: readFileSync(new URL(path, import.meta.url), "utf8"),
}));
describe("settings select styling", () => {
it("uses the shared Select component instead of native select controls", () => {
for (const { path, source } of settingsSources) {
expect(source, path).not.toMatch(/<select(?:\s|>)/);
}
});
});
@@ -1,10 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dangerDialogHostSource = readFileSync(new URL("../SidebarDangerDialogHost.vue", import.meta.url), "utf8");
describe("Shared danger dialog guard", () => {
it("refuses to open a new danger dialog while a previous danger operation is still running", () => {
expect(dangerDialogHostSource).toMatch(/function showSidebarDangerDialog\(request: SidebarDangerDialogRequest\) \{\s*if \(sidebarDangerRunningExecutionId\.value[^\n]*\) \{\s*toast\(t\("contextMenu\.dangerOperationAlreadyRunning"\), \d+\);\s*return;\s*\}/);
});
});
@@ -1,64 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const runtimeHostSource = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
const dangerDialogHostSource = readFileSync(new URL("../SidebarDangerDialogHost.vue", import.meta.url), "utf8");
const dangerDialogSource = readFileSync(new URL("../../editor/DangerConfirmDialog.vue", import.meta.url), "utf8");
/**
* `isElasticsearchClearConfirmed` is unit-tested on its own, but a correct pure
* function is worthless if the dialog never consults it. These assertions pin
* the wiring that connects the two, end to end through the shared dialog.
*/
describe("Elasticsearch clear-index wildcard guard wiring", () => {
it("gates the confirm button on the typed pattern for wildcard nodes only", () => {
const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm"));
expect(clearRoute).toContain("const isPattern = isElasticsearchIndexPattern(index)");
expect(clearRoute).toMatch(/\.\.\.\(isPattern\s*\?\s*\{/);
});
/**
* Regression: the gate once lived inside the `...(isPattern ? {…} : {})`
* spread. Object spread *invokes* an accessor and copies the resulting value
* as a plain data property, so the getter answered once at construction time
* — while the input was still empty — and the confirm button stayed disabled
* no matter what was typed. The wording read correctly and the getter was
* present in the source the whole time, so only its position catches this.
*/
it("declares confirmDisabled outside the spread, so it stays a live getter", () => {
const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm"), runtimeHostSource.indexOf("routeDangerDialog(showFlushRedisDbConfirm"));
const spreadStart = clearRoute.indexOf("...(isPattern");
const spreadEnd = clearRoute.indexOf(": {}),", spreadStart);
expect(spreadStart).toBeGreaterThan(-1);
expect(spreadEnd).toBeGreaterThan(spreadStart);
// Nothing spread into the request may be an accessor.
expect(clearRoute.slice(spreadStart, spreadEnd)).not.toMatch(/\bget\s+\w+\s*\(/);
// It has to sit directly on the request literal instead.
expect(clearRoute.slice(spreadEnd)).toMatch(/get confirmDisabled\(\) \{\s*return !isElasticsearchClearConfirmed\(index, clearElasticsearchIndexTypedName\.value\);/);
});
it("pins the index label for the life of the dialog instead of following the tree selection", () => {
const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm"), runtimeHostSource.indexOf("routeDangerDialog(showFlushRedisDbConfirm"));
expect(clearRoute).toContain("const index = activeNode.value.label;");
// Reading activeNode again inside the request would reintroduce the drift.
expect(clearRoute.match(/activeNode\.value/g)).toHaveLength(1);
});
it("clears any previously typed name when the dialog is opened again", () => {
const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm"), runtimeHostSource.indexOf("routeDangerDialog(showFlushRedisDbConfirm"));
expect(clearRoute).toContain('clearElasticsearchIndexTypedName.value = "";');
});
it("forwards confirmDisabled from the sidebar request to the shared danger dialog", () => {
expect(dangerDialogHostSource).toContain(':confirm-disabled="sidebarDangerDialogRequest.confirmDisabled"');
});
it("honours confirmDisabled on both the button and the confirm handler", () => {
expect(dangerDialogSource).toContain(':disabled="loading || confirmDisabled"');
expect(dangerDialogSource).toMatch(/function onConfirm\(\) \{[\s\S]*?if \(props\.loading \|\| props\.confirmDisabled\) return;/);
});
it("leaves confirmDisabled off by default so existing danger dialogs are unaffected", () => {
expect(dangerDialogSource).toContain("confirmDisabled: false,");
});
});
@@ -1,34 +0,0 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const runtimeSource = readFileSync(fileURLToPath(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url)), "utf8");
const treeItemSource = readFileSync(fileURLToPath(new URL("../TreeItem.vue", import.meta.url)), "utf8");
function functionSource(name: string): string {
const start = runtimeSource.indexOf(`function ${name}(`);
expect(start, `${name} not found`).toBeGreaterThan(-1);
const next = runtimeSource.indexOf("\nfunction ", start + 1);
return runtimeSource.slice(start, next === -1 ? undefined : next);
}
describe("Xugu public synonym actions", () => {
it("returns a read-only schema menu before default and destructive actions", () => {
const menu = functionSource("buildDatabaseSidebarMenu");
const readOnlyGuard = menu.indexOf("isXuguSyntheticTreeNode");
expect(readOnlyGuard).toBeGreaterThan(-1);
expect(readOnlyGuard).toBeLessThan(menu.indexOf("contextMenu.setDefaultSchema"));
expect(readOnlyGuard).toBeLessThan(menu.indexOf("contextMenu.dropSchema"));
});
it("guards both the drop request and confirmed execution", () => {
expect(functionSource("dropSchema")).toContain("isXuguSyntheticTreeNode");
expect(functionSource("confirmDropSchema")).toContain("isXuguSyntheticTreeNode");
});
it("uses a distinct link icon for the database-global synonym scope", () => {
expect(treeItemSource).toContain("isXuguPublicSynonymTreeNode(databaseType, node.type, node.schema)");
expect(treeItemSource).toContain('return { icon: Link2, colorClass: "text-sky-500" }');
});
});
@@ -1,7 +1,5 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
@@ -62,8 +60,6 @@ vi.mock("@/components/ui/button", async () => {
import SshHostKeyPromptDialog from "@/components/ssh/SshHostKeyPromptDialog.vue";
const dialogSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue"), "utf8");
class MockEventSource {
static instances: MockEventSource[] = [];
@@ -128,13 +124,6 @@ async function mountDialog() {
}
describe("SshHostKeyPromptDialog web bridge", () => {
it("requires an explicit answer for blocking SSH prompts", () => {
expect(dialogSource).toContain(':show-close-button="false"');
expect(dialogSource).toContain("@interact-outside.prevent");
expect(dialogSource).toContain("@escape-key-down.prevent");
expect(dialogSource).not.toContain(':dismissible="false"');
});
it("keeps the prompt above a later body-mounted connection dialog and answerable", async () => {
await mountDialog();
@@ -376,8 +365,6 @@ describe("SshHostKeyPromptDialog web bridge", () => {
expect(document.body.textContent).toContain("203.0.113.10:22");
expect(document.body.textContent).toContain("08ca4746e8fbf97038a93105d3ef023112e3f66bc097869401628c00acea7709");
expect(document.body.textContent).toContain("/home/testuser/.cache/dbx/sqlite-worker/");
expect(dialogSource).toContain("break-all");
expect(dialogSource).toContain("shrink-0");
const buttons = [...document.body.querySelectorAll("button")].map((button) => button.textContent?.trim());
expect(buttons).toContain("Cancel");
@@ -1,43 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../TableStructureEditor.vue", import.meta.url), "utf8");
const scopedStyleStart = source.indexOf("<style scoped>");
const scopedStyleEnd = source.indexOf("</style>", scopedStyleStart);
const scopedStyle = source.slice(scopedStyleStart, scopedStyleEnd).replace(/\/\*[\s\S]*?\*\//g, "");
const rules = [...scopedStyle.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((match) => ({
selectors: match[1]!.split(",").map((selector) => selector.trim()),
declarations: match[2]!,
}));
describe("TableStructureEditor selected-row styles", () => {
it("keeps the highlight on cells without repainting descendant controls", () => {
const selectedBackgroundRules = rules.filter(({ selectors, declarations }) => selectors.some((selector) => selector.includes(".structure-column-search-current")) && /background(?:-color)?\s*:/.test(declarations));
expect(selectedBackgroundRules).toHaveLength(1);
expect(selectedBackgroundRules[0]?.selectors).toEqual([".structure-column-search-current > td"]);
expect(selectedBackgroundRules[0]?.declarations).toContain("var(--primary)");
const gridControlRule = rules.find(({ selectors }) => selectors.includes(".structure-edit-grid :deep(.structure-grid-control)"));
expect(gridControlRule?.declarations).toContain("background-color: transparent;");
});
it("keeps HeidiSQL-style column type and key tone hooks wired", () => {
expect(source).toContain("structureDataTypeToneClass(column.dataType)");
expect(source).toContain("structure-column-primary-key");
expect(source).toContain("structure-column-unique-key");
for (const className of ["structure-data-type-numeric", "structure-data-type-text", "structure-data-type-temporal", "structure-data-type-json", "structure-data-type-binary", "structure-data-type-boolean", "structure-data-type-enum", "structure-data-type-spatial"]) {
expect(scopedStyle).toContain(className);
}
});
it("guards index column options and column issues against hot-path recalculations (#11637)", () => {
expect(source).toContain("canIndexTypeHaveSpecialIssue(structureDialect.value, indexType)");
expect(source).toContain("canIndexTypeHaveSpecialIssue(structureDialect.value, index.indexType)");
expect(source).toContain("defaultIndexColumnOptions = computed");
expect(source).toContain("activeColumnByName = computed");
expect(source).toContain("activeColumnByComparisonKey = computed");
expect(source).toContain("indexesByColumnComparisonKey = computed");
});
});
@@ -1,43 +0,0 @@
// Source-contract guard for shared button press feedback.
// Overview: desktop click responsiveness depends on buttons giving immediate
// visual feedback while pressed. DOM `click` only fires on pointer-up, so a
// press with zero visual delta reads as "the click did nothing" and users
// retry or hold the button (~1s holds measured in click-probe sessions).
//
// Two guarantees keep that regression from re-appearing:
// 1. Every button variant styles an `active:` pressed state, so pressing
// always changes something on screen before pointer-up.
// 2. The pressed state must not move or scale the button (`scale-*` /
// `translate-*`). A moving hit target can land pointer-up outside the
// element it was pressed on, which genuinely drops the click.
// Keep these assertions updated alongside the variant class strings.
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const buttonVariantsSource = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
const VARIANTS = ["default", "outline", "secondary", "ghost", "destructive", "link"] as const;
function variantClasses(variant: string): string {
const match = buttonVariantsSource.match(new RegExp(`${variant}:\\s*"([^"]+)"`));
expect(match, `variant "${variant}" exists in buttonVariants`).toBeTruthy();
return match![1];
}
describe("button press feedback guard", () => {
it.each(VARIANTS)("styles an active pressed state for the %s variant", (variant) => {
expect(variantClasses(variant)).toMatch(/(^|\s)active:/);
});
it.each(VARIANTS)("does not move the hit target while %s is pressed", (variant) => {
expect(variantClasses(variant)).not.toMatch(/active:(scale|translate|-translate)-/);
});
it("does not move the hit target from the shared base classes", () => {
const base = buttonVariantsSource.match(/cva\(\s*"([^"]+)"/);
expect(base).toBeTruthy();
expect(base![1]).not.toMatch(/active:(scale|translate|-translate)-/);
});
});
@@ -429,18 +429,4 @@ describe("background image theme and scope lifecycle", () => {
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).not.toHaveBeenCalled();
});
it("clears body, #root, outer app shell, welcome screen and CodeMirror scroller in globals.css", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const globalsCss = fs.readFileSync(path.resolve(__dirname, "../../styles/globals.css"), "utf8");
expect(globalsCss).toContain("html.dbx-bg-active,");
expect(globalsCss).toContain("html.dbx-bg-active body,");
expect(globalsCss).toContain("html.dbx-bg-active #root,");
expect(globalsCss).toContain("html.dbx-bg-active [data-app-shell],");
expect(globalsCss).toContain("html.dbx-bg-active [data-welcome-screen],");
expect(globalsCss).toContain("html.dbx-bg-active .cm-editor,");
expect(globalsCss).toContain("html.dbx-bg-active .cm-gutters,");
expect(globalsCss).toContain("html.dbx-bg-active .cm-scroller");
});
});
@@ -1,46 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// The editor's "Send to AI" entry (#10058) resolves its chip copy through
// `ai.selectionChipLabel` / `ai.selectionChipDetail` and reports a deleted
// source connection through `ai.externalTargetUnavailable`. Imported locale
// modules are deep-merged with English by `withEnglishFallback`, so a locale
// that silently lost one of these keys would still resolve — to English text.
// Assert the locale SOURCES instead, pre-merge.
function localeSource(name: string): string {
return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8");
}
// Source of truth: `supportedLocales` in ../index.ts (13 entries, `ru` included).
const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "es", "it", "pt-BR", "ru", "az", "th", "tr", "id"] as const;
function declaredString(source: string, key: string): string | undefined {
return source.match(new RegExp(String.raw`^\s*${key}:\s*"((?:[^"\\]|\\.)*)",\s*$`, "m"))?.[1];
}
describe("editor selection context locale parity", () => {
it.each(LOCALES)("%s declares the selection-context keys", (name) => {
const source = localeSource(name);
expect(declaredString(source, "selectionChipLabel"), `${name}: ai.selectionChipLabel is missing`).toBeTruthy();
expect(declaredString(source, "externalTargetUnavailable"), `${name}: ai.externalTargetUnavailable is missing`).toBeTruthy();
// Both placeholders are substituted from the chip title: dropping one renders
// the raw `{name}`/`{count}` to the user.
const detail = declaredString(source, "selectionChipDetail");
expect(detail, `${name}: ai.selectionChipDetail is missing`).toBeTruthy();
expect(detail).toContain("{name}");
expect(detail).toContain("{count}");
});
it.each(LOCALES)("%s anchors the keys in the attachment cluster", (name) => {
const source = localeSource(name);
const truncated = source.indexOf("attachmentTruncatedStatus:");
const selectionLabel = source.indexOf("selectionChipLabel:");
// Anchored next to the attachment strings they belong with, so the cluster
// stays reviewable (i18n-locales.md: cluster anchoring).
expect(truncated).toBeGreaterThanOrEqual(0);
expect(selectionLabel).toBeGreaterThan(truncated);
});
});
@@ -148,6 +148,13 @@ const FEATURE_MESSAGES: FeatureMessages[] = [
translated: true,
},
{ feature: "schema diff deploy ignores foreign key checks", keys: under("diff", ["ignoreForeignKeyChecks", "ignoreForeignKeyChecksHint"]), translated: true },
{ feature: "shortcut recorder", keys: under("settings", ["shortcutRecordKeys", "shortcutRecordKeysRecordingHint", "shortcutRecordKeysWindowInactive"]), locales: ALL_LOCALES.filter((locale) => locale !== "th") },
{ feature: "select current statement", keys: ["settings.shortcutSelectCurrentStatement", "editor.contextMenu.selectCurrentStatement"] },
{ feature: "MCP history kind", keys: ["history.kinds.mcp"] },
{ feature: "editor selection to AI (#10058)", keys: under("ai", ["selectionChipLabel", "selectionChipDetail", "externalTargetUnavailable"]) },
{ feature: "SQL formatter layout style", keys: under("settings", ["sqlFormatterLayoutStyle", "sqlFormatterLayoutStyleDbx", "sqlFormatterLayoutStyleClassic"]) },
{ feature: "sidebar table structure shortcut", keys: ["settings.shortcutEditSidebarTableStructure"], locales: ALL_LOCALES.filter((locale) => locale !== "th") },
{ feature: "MySQL AUTO_INCREMENT warnings", keys: under("structureEditor", ["mysqlAutoIncrementSingleColumn", "mysqlAutoIncrementRequiresIndex", "mysqlAutoIncrementRequiresNativeColumn"]) },
];
function lookup(messages: unknown, path: string): unknown {
@@ -1,50 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Issue #10117: the grid duration hints and the meilisearch result summary
// switched from fixed-unit placeholders ("{ms} ms", "{us} µs", "{time} 毫秒")
// to the pre-formatted "{duration}" placeholder produced by
// formatQueryDuration/formatDurationUs. Assertions run against locale SOURCE
// text: imported modules are deep-merged with English via withEnglishFallback,
// so a dropped key would be invisible post-merge.
function localeSource(name: string): string {
return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8");
}
function declaredValue(source: string, key: string): string | undefined {
return source.match(new RegExp(String.raw`^\s*${key}:\s*"((?:[^"\\]|\\.)*)",\s*$`, "m"))?.[1];
}
const GRID_DURATION_KEYS = ["serverExecuteTime", "agentExecuteTime", "clientRequestWait", "resultViewUpdate"] as const;
// Locales that carry the grid duration cluster; az/tr intentionally do not and
// fall back to en.
const GRID_DURATION_LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "es", "it", "pt-BR", "ru"] as const;
const RESULT_SUMMARY_LOCALES = [...GRID_DURATION_LOCALES, "az", "tr"];
describe("grid duration hint placeholder parity", () => {
it.each(GRID_DURATION_LOCALES.flatMap((name) => GRID_DURATION_KEYS.map((key) => ({ name, key }))))("$name: grid.$key uses {duration}", ({ name, key }) => {
const value = declaredValue(localeSource(name), key);
expect(value, `${name}: grid.${key} is missing`).toBeDefined();
expect(value, `${name}: grid.${key}`).toContain("{duration}");
expect(value, `${name}: grid.${key}`).not.toMatch(/\{(ms|us|time)\}/);
});
it("az/tr keep falling back to en for the grid duration cluster", () => {
for (const name of ["az", "tr"] as const) {
for (const key of GRID_DURATION_KEYS) {
expect(declaredValue(localeSource(name), key), `${name}: grid.${key} should stay undeclared`).toBeUndefined();
}
}
});
});
describe("meilisearch resultSummary placeholder parity", () => {
it.each(RESULT_SUMMARY_LOCALES)("%s: meilisearch.resultSummary uses {duration}", (name) => {
const value = declaredValue(localeSource(name), "resultSummary");
expect(value, `${name}: meilisearch.resultSummary is missing`).toBeDefined();
expect(value, `${name}: meilisearch.resultSummary`).toContain("{count}");
expect(value, `${name}: meilisearch.resultSummary`).toContain("{duration}");
expect(value, `${name}: meilisearch.resultSummary`).not.toMatch(/\{(ms|us|time)\}/);
});
});
@@ -1,26 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Non-SQL MCP history entries persist activity_kind "mcp" and the details
// dialog resolves the label through `history.kinds.${kind}`. Assert every
// locale that carries the history kinds cluster declares the mcp entry:
// imported modules are deep-merged with English via withEnglishFallback, so a
// dropped key would be invisible post-merge.
function localeSource(name: string): string {
return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8");
}
function historyKindsCluster(source: string): string | undefined {
return (source.match(/kinds: \{[^}]*\}/g) ?? []).find((cluster) => cluster.includes("redis_command:"));
}
const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "es", "it", "pt-BR", "ru", "az", "tr", "th", "id"] as const;
describe("history kinds mcp label parity", () => {
it.each(LOCALES)("%s: history.kinds declares mcp", (name) => {
const cluster = historyKindsCluster(localeSource(name));
expect(cluster, `${name}: history kinds cluster not found`).toBeDefined();
expect(cluster, `${name}: history.kinds.mcp is missing`).toMatch(/(^|[^A-Za-z_])mcp:\s*"/);
});
});
@@ -1,26 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// 「选中当前语句」的快捷键设置项与右键菜单文案,必须由每种语言的源文件自己声明:
// 导入的 locale 会经 `withEnglishFallback` 深合并英文,缺 key 时仍能解析(但会显示英文),
// 因此这里直接断言源文件(合并前),避免静默回落。
function localeSource(name: string): string {
return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8");
}
// 与 ../index.ts 的 supportedLocales 保持一致(13 种语言)
const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "es", "it", "pt-BR", "ru", "az", "th", "tr", "id"] as const;
function declaredString(source: string, key: string): string | undefined {
return source.match(new RegExp(String.raw`^\s*${key}:\s*"((?:[^"\\]|\\.)*)",\s*$`, "m"))?.[1];
}
describe("select current statement locale parity", () => {
it.each(LOCALES)("%s declares the shortcut and context-menu labels", (name) => {
const source = localeSource(name);
expect(declaredString(source, "shortcutSelectCurrentStatement"), `${name}: settings.shortcutSelectCurrentStatement is missing`).toBeTruthy();
expect(declaredString(source, "selectCurrentStatement"), `${name}: editor.contextMenu.selectCurrentStatement is missing`).toBeTruthy();
});
});
@@ -1,27 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// #11787 的快捷键录入按钮与录入态提示,必须由每种语言的源文件自己声明:导入的 locale
// 会经 `withEnglishFallback` 深合并英文,缺 key 时仍能解析(但会显示英文),因此这里
// 直接断言源文件(合并前),避免静默回落。
function localeSource(name: string): string {
return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8");
}
// 与 ../index.ts 的 supportedLocales 保持一致(12 种语言)
const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "es", "it", "pt-BR", "ru", "az", "tr", "id"] as const;
function declaredString(source: string, key: string): string | undefined {
return source.match(new RegExp(String.raw`^\s*${key}:\s*"((?:[^"\\]|\\.)*)",\s*$`, "m"))?.[1];
}
describe("shortcut record-keys locale parity", () => {
it.each(LOCALES)("%s declares the recorder button and recording hint", (name) => {
const source = localeSource(name);
expect(declaredString(source, "shortcutRecordKeys"), `${name}: settings.shortcutRecordKeys is missing`).toBeTruthy();
expect(declaredString(source, "shortcutRecordKeysRecordingHint"), `${name}: settings.shortcutRecordKeysRecordingHint is missing`).toBeTruthy();
expect(declaredString(source, "shortcutRecordKeysWindowInactive"), `${name}: settings.shortcutRecordKeysWindowInactive is missing`).toBeTruthy();
});
});
@@ -1,54 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
/**
* Every locale must declare the SQL formatter layout-style strings in its own
* source file, not fall back to English. The check has to read the raw source:
* each non-en locale is wrapped in `withEnglishFallback(...)`, which deep-merges
* `en` under the locale at import time, so asserting on the imported modules
* would report the key as present even after a locale drops it.
*/
const LOCALE_FILES = ["az", "en", "es", "id", "it", "ja", "ko", "pt-BR", "ru", "th", "tr", "zh-CN", "zh-TW"];
const LAYOUT_STYLE_KEYS = ["sqlFormatterLayoutStyle", "sqlFormatterLayoutStyleDbx", "sqlFormatterLayoutStyleClassic"];
function localeSource(name: string): string {
return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8");
}
function declaredString(source: string, key: string): string | undefined {
return source.match(new RegExp(String.raw`^\s*${key}:\s*"((?:[^"\\]|\\.)*)",\s*$`, "m"))?.[1];
}
describe("SQL formatter layout style i18n parity", () => {
it.each(LOCALE_FILES)("%s declares every layout-style key in its source", (name) => {
const source = localeSource(name);
for (const key of LAYOUT_STYLE_KEYS) {
expect(declaredString(source, key), `${name} is missing ${key}`).toBeTruthy();
}
});
it.each(LOCALE_FILES)("%s anchors the cluster after the indent-style keys", (name) => {
const source = localeSource(name);
const anchor = source.indexOf("sqlFormatterIndentStyleTabularRight:");
const layoutStyle = source.indexOf("sqlFormatterLayoutStyle:");
expect(anchor).toBeGreaterThan(-1);
expect(layoutStyle).toBeGreaterThan(anchor);
});
it("en carries the labels the settings panel shows", () => {
const source = localeSource("en");
expect(declaredString(source, "sqlFormatterLayoutStyle")).toBe("Layout style");
expect(declaredString(source, "sqlFormatterLayoutStyleDbx")).toBe("DBX");
expect(declaredString(source, "sqlFormatterLayoutStyleClassic")).toBe("Classic");
});
it("zh-CN translates the labels instead of leaving them in English", () => {
const source = localeSource("zh-CN");
expect(declaredString(source, "sqlFormatterLayoutStyle")).toBe("排版风格");
expect(declaredString(source, "sqlFormatterLayoutStyleClassic")).toContain("经典");
});
});
@@ -1,18 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
describe("ConnectionDialog production protection section", () => {
it("hides the production protection section for plugin connections", () => {
expect(dialogSource).toContain('<div v-if="!isPluginConnection" class="grid grid-cols-4 items-start gap-4 rounded-[6px] border border-red-500/25 bg-red-500/[0.035] px-3 py-2.5">');
});
it("clears stale production flags when submitting plugin connections", () => {
const submitFn = dialogSource.slice(dialogSource.indexOf("function connectionConfigForSubmit"));
const pluginBranch = submitFn.slice(0, submitFn.indexOf("} else {"));
expect(pluginBranch).toContain("config.is_production = false;");
expect(pluginBranch).toContain("config.production_databases = [];");
expect(pluginBranch).not.toContain("config.is_production = form.value.is_production;");
});
});
@@ -1,9 +1,6 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { DEFAULT_CONNECT_TIMEOUT_SECS, DEFAULT_IDLE_DISCONNECT_SECS, DEFAULT_IDLE_TIMEOUT_SECS, MAX_IDLE_DISCONNECT_SECS, MAX_IDLE_TIMEOUT_SECS, normalizeConnectTimeoutSecs, normalizeIdleDisconnectSecs, normalizeIdleTimeoutSecs, supportsIdleTimeout } from "@/lib/connection/timeoutLimits";
const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
describe("ConnectionDialog timeout controls", () => {
it("restores the default when the connection timeout input is cleared", () => {
expect(normalizeConnectTimeoutSecs("")).toBe(DEFAULT_CONNECT_TIMEOUT_SECS);
@@ -44,30 +41,4 @@ describe("ConnectionDialog timeout controls", () => {
expect(supportsIdleTimeout("oracle")).toBe(false);
expect(supportsIdleTimeout(undefined)).toBe(false);
});
it("binds idle timeout setting and hint in ConnectionDialog", () => {
expect(dialogSource).toContain('v-show="supportsIdleTimeoutSetting"');
expect(dialogSource).toContain("clampIdleTimeoutInput($event)");
expect(dialogSource).toContain('t("connection.idleTimeoutHint")');
});
it("binds the auto-disconnect-when-idle input for every dialect", () => {
// 该输入框不做方言门控:不挂在 supportsIdleTimeoutSetting 的 v-show 块内,也没用
// supportsIdleTimeout 之类的判断。
expect(dialogSource).toContain("clampIdleDisconnectInput($event)");
expect(dialogSource).toContain('t("connection.idleDisconnect")');
expect(dialogSource).toContain('v-model.number="form.idle_disconnect_secs"');
// 开关+输入框与「保持连接间隔」一行保持一致;说明文案收进小问号 tooltip,
// 避免和「空闲超时」的说明挤在一起。
expect(dialogSource).toContain('v-model="idleDisconnectEnabled"');
expect(dialogSource).toContain(':disabled="!idleDisconnectEnabled"');
expect(dialogSource).toContain(":label=\"t('connection.idleDisconnectHint')\"");
});
it("warns when the idle auto-disconnect threshold can never beat keepalive", () => {
// 软校验:阈值不大于「保持连接间隔」时给出提示,但不阻止保存。
expect(dialogSource).toContain("idleDisconnectKeepaliveWarning");
expect(dialogSource).toContain('t("connection.idleDisconnectKeepaliveWarning")');
expect(dialogSource).toContain("const idleDisconnectKeepaliveWarning = computed");
});
});
@@ -1,14 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
describe("Nacos connection dialog layout", () => {
it("uses namespaces when scoping Nacos production safeguards", () => {
expect(source).toContain('form.value.db_type === "nacos"');
expect(source).toContain("production.allNamespaces");
expect(source).toContain("production.namespacePickerTitle");
expect(source).toContain("loadReadableNacosNamespaces(connectionId, api)");
expect(source).toContain("nacosNamespaceIdentity(name)");
});
});
@@ -1,54 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
/**
* Admin SQL (drop / empty / truncate / clone / copy) is built in Rust from the database type alone,
* which cannot describe a database whose quote character depends on the connection. Cloud Spanner is
* the case that forces the connection-reported quote to be threaded through: GoogleSQL quotes with
* backticks, its PostgreSQL dialect quotes with double quotes, and the dialect is fixed when the
* database is created. Emitting the static answer produces admin SQL that is a syntax error for
* every PostgreSQL-dialect Spanner database.
*
* These assertions pin the call sites rather than the generated SQL, because the plumbing is what
* regressed: the backend already knew how to honour a reported quote (`data_grid_sql.rs` carries the
* same field) and the builders simply were not given one.
*/
const mutationRuntimeSource = readFileSync(new URL("../../../composables/useSidebarTableMutationRuntime.ts", import.meta.url), "utf8");
const treeRuntimeSource = readFileSync(new URL("../../../components/sidebar/SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
const objectBrowserSource = readFileSync(new URL("../../../components/objects/ObjectBrowser.vue", import.meta.url), "utf8");
const adminSqlSource = readFileSync(new URL("../../database/dbAdminSql.ts", import.meta.url), "utf8");
describe("admin SQL carries the connection identifier quote", () => {
it("declares the field on every option type whose SQL qualifies an object name", () => {
for (const type of ["DropObjectSqlOptions", "TableAdminSqlOptions", "DuplicateTableStructureSqlOptions", "CopyTableDataSqlOptions"]) {
const body = adminSqlSource.match(new RegExp(`export interface ${type} \\{([\\s\\S]*?)\\n\\}`))?.[1] ?? "";
expect(body, `${type} must accept identifierQuote`).toContain("identifierQuote?: string;");
}
});
it("threads the quote through drop, empty and truncate from the sidebar", () => {
// Both factories: one reads the active node, the other an explicit node during multi-select.
expect(mutationRuntimeSource).toMatch(/tableName: activeNode\.value\.label,[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(activeNode\.value\.connectionId\)/);
expect(mutationRuntimeSource).toMatch(/tableName: node\.label,\s*identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(node\.connectionId\)/);
});
it("threads the quote through dropping a view or routine", () => {
expect(treeRuntimeSource).toMatch(/signature: node\.signature,[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(node\.connectionId\)/);
expect(objectBrowserSource).toMatch(/buildDropObjectSql\(\{[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(props\.connection\.id\)/);
});
it("threads the quote through clone and cross-database paste", () => {
expect(treeRuntimeSource).toMatch(/buildDuplicateTableStructurePlan\(\{[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(node\.connectionId\)/);
expect(treeRuntimeSource).toMatch(/buildDuplicateTableStructurePlan\(\{[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(entry\.connectionId\)/);
expect(objectBrowserSource).toMatch(/buildSharedDuplicateTableStructurePlan\(\{[\s\S]*?identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(props\.connection\.id\)/);
// The plan helper must forward it to the SQL builder rather than swallowing it.
expect(adminSqlSource).toMatch(/buildDuplicateTableStructureSql\(\{[\s\S]*?identifierQuote: options\.identifierQuote/);
});
it("threads the quote through copying table data", () => {
expect(treeRuntimeSource).toMatch(/identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(entry\.connectionId\)/);
expect(treeRuntimeSource).toMatch(/buildCopyTableDataSql\(\{[\s\S]*?identifierQuote: entry\.identifierQuote/);
expect(objectBrowserSource).toMatch(/identifierQuote: connectionStore\.connectionIdentifierQuote\?\.\(props\.connection\.id\)/);
expect(objectBrowserSource).toMatch(/buildCopyTableDataSql\(\{[\s\S]*?identifierQuote: context\.identifierQuote/);
});
});
@@ -1,27 +1,10 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { describe, expect, it, vi } from "vitest";
import { createQueryEditorExecutionViewportOwnership, isQueryEditorPositionVisible, locateCursorForGutterExecution } from "../../editor/queryEditorExecutionViewport";
const specDir = path.dirname(fileURLToPath(import.meta.url));
const queryEditorSource = ["QueryEditor.vue", "useQueryEditorExecution.ts"].map((file) => readFileSync(path.resolve(specDir, "../../../components/editor", file), "utf8")).join("\n");
describe("QueryEditor execution routing", () => {
it("routes the execution shortcut through the shared execution-mode contract while bypassing the picker", () => {
expect(queryEditorSource).toContain("createQueryEditorExecutionShortcutBindings(shortcuts.executeSql");
expect(queryEditorSource).not.toContain("forceCurrent");
});
it("snapshots pre-execution cursor visibility when execution viewport tracking starts", () => {
expect(queryEditorSource).toContain("beginExecution(cursorVisible)");
});
});
describe("QueryEditor gutter execution cursor positioning", () => {
function createEditor(doc: string, selection?: { anchor: number; head?: number }): EditorView {
return new EditorView({
@@ -15,7 +15,6 @@ import {
updateMcpAllowedConnectionIds,
} from "@/lib/mcp/mcpPolicySelection";
const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
const mcpServerSource = readFileSync(new URL("../../../../../../crates/dbx-mcp/src/server.rs", import.meta.url), "utf8");
describe("MCP execution permission selection", () => {
@@ -120,8 +119,6 @@ describe("MCP tool permission selection", () => {
expect(toggleMcpAllowedToolName(["dbx_execute_query"], "dbx_execute_batch", true)).toEqual(["dbx_execute_query", "dbx_execute_batch"]);
expect(toggleMcpAllowedToolName(["dbx_execute_query", "dbx_execute_batch"], "dbx_execute_batch", false)).toEqual(["dbx_execute_query"]);
expect(MCP_TOOL_OPTIONS.find((tool) => tool.name === "dbx_execute_batch")?.labelKey).toBe("settings.mcpToolExecuteBatch");
expect(settingsDialogSource).toContain("const mcpToolOptions = MCP_TOOL_OPTIONS;");
expect(settingsDialogSource).toContain("toggleMcpAllowedToolName(mcpAllowedToolNames.value, name, allowed)");
});
});
@@ -132,19 +129,6 @@ describe("MCP policy settings state", () => {
expect(isMcpPolicyMutationBlocked({ loading: false, saving: false, loadError: "unavailable" })).toBe(true);
expect(isMcpPolicyMutationBlocked({ loading: false, saving: false, loadError: "" })).toBe(false);
});
it("guards the mutation entry point and wires the shared disabled state to policy controls", () => {
expect(settingsDialogSource).toContain("if (mcpPolicyControlsDisabled.value) return;");
expect(settingsDialogSource).toContain(':disabled="mcpPolicyControlsDisabled"');
expect(settingsDialogSource).toContain('@update:scope="onMcpResourceScopeChange"');
const loadingStart = settingsDialogSource.indexOf("mcpPolicyLoading.value = true;");
const policyLoad = settingsDialogSource.indexOf("await settingsStore.initMcpGlobalPolicy(true);");
const loadingEnd = settingsDialogSource.indexOf("mcpPolicyLoading.value = false;", policyLoad);
expect(loadingStart).toBeGreaterThan(-1);
expect(loadingStart).toBeLessThan(policyLoad);
expect(loadingEnd).toBeGreaterThan(policyLoad);
});
});
describe("MCP connection search", () => {
@@ -1,32 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
const tauriBackendSource = readFileSync(new URL("../../../lib/backend/tauri.ts", import.meta.url), "utf8");
const httpBackendSource = readFileSync(new URL("../../../lib/backend/http.ts", import.meta.url), "utf8");
describe("MCP server management", () => {
it("makes native installation the default while preserving npm as a migration fallback", () => {
expect(settingsDialogSource).toContain('mcpStatus.value?.installation_source === "npm"');
expect(settingsDialogSource).toContain('t("settings.mcpSwitchToNativeButton")');
expect(settingsDialogSource).toContain('t("settings.mcpNativeMigrationHint")');
expect(settingsDialogSource).toContain("await installNativeMcpServer()");
expect(settingsDialogSource).toContain("await uninstallNpmMcpServer()");
expect(settingsDialogSource).toContain("mcpStatus?.installation_source !== 'npm' && mcpStatus?.npm_installed");
expect(settingsDialogSource).toContain(':disabled="mcpInstallDisabled"');
expect(settingsDialogSource).not.toContain("!mcpStatus?.npm_available");
expect(tauriBackendSource).toContain('installation_source: "native" | "homebrew" | "npm" | null');
expect(tauriBackendSource).toContain('invoke("install_native_mcp_server")');
expect(tauriBackendSource).toContain('invoke("uninstall_npm_mcp_server")');
});
it("exposes a confirmed uninstall action only for an installed desktop server", () => {
expect(settingsDialogSource).toContain('v-if="!isWeb && mcpStatus?.installed"');
expect(settingsDialogSource).toContain('window.confirm(t("settings.mcpUninstallConfirm"))');
expect(settingsDialogSource).toContain("await uninstallMcpServer()");
expect(settingsDialogSource).toContain('t("settings.mcpUninstallCommand")');
expect(settingsDialogSource).toContain("copyMcpText('uninstall', mcpUninstallCommand)");
expect(tauriBackendSource).toContain('invoke("uninstall_mcp_server")');
expect(httpBackendSource).toContain("MCP Server uninstallation is only available in the desktop app.");
});
});
@@ -109,16 +109,4 @@ describe("Redis value detection (issue 10922)", () => {
expect(stringViewInit).toContain('readStoredRedisValueFormat() ?? "utf8"');
expect(stringCodecInit).toContain('readStoredRedisValueCodec() ?? "none"');
});
it("labels the detected representation next to the value on both surfaces", () => {
const template = parsedViewer.descriptor.template;
expect(parsedViewer.errors).toEqual([]);
expect(template).toBeDefined();
expect(viewerSource).toContain('v-if="stringValueDetectionLabel"');
expect(viewerSource).toContain('v-if="memberDetectionLabel"');
expect(viewerSource.match(/t\('redis\.detectedBadgeTitle'\)/g)).toHaveLength(4);
expect(findFunction("redisDetectionBadgeLabel").getText()).toContain('t("redis.detectedJavaSerialized")');
expect(findFunction("redisDetectionBadgeLabel").getText()).toContain("isRedisJsonContainerValue(detail.json?.value)");
});
});
@@ -1,4 +1,3 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import type { TreeNode } from "@/types/database";
import { sidebarNodeSupportsStructureEdit } from "@/lib/sidebar/sidebarTreeStructureShortcut";
@@ -37,12 +36,4 @@ describe("sidebar tree structure shortcut", () => {
it("rejects SQL Server linked server nodes", () => {
expect(sidebarNodeSupportsStructureEdit(node("table", { linkedServer: "REMOTE" }), "sqlserver")).toBe(false);
});
const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "es", "it", "pt-BR", "ru", "az", "tr", "id"] as const;
it.each(LOCALES)("declares shortcutEditSidebarTableStructure in locale %s", (locale) => {
const file = new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url);
const content = readFileSync(file, "utf8");
expect(content).toMatch(/^\s*shortcutEditSidebarTableStructure:\s*".+",\s*$/m);
});
});
@@ -1,78 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Regression coverage for https://github.com/t8y2/dbx/issues/11517.
//
// Every ad-hoc "new query" entry (app toolbar, keyboard shortcut, welcome
// screen, editor-group "+") funnels into App.vue's newQuery(). That shared entry
// used to carry a sticky `newQueryContextSource`, which the sidebar selection
// watcher set to "sidebar" while only the active-tab watcher and editor-group
// "+" reset it. The global toolbar and shortcut could therefore use a stale
// sidebar connection instead of the editor the user was looking at.
//
// The precedence itself is unit-tested in newQueryContext.spec.ts; this suite
// pins the App.vue wiring, because App.vue is a large SFC the suite never mounts.
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
function bodyOf(fnSignature: string): string {
const start = appSource.indexOf(fnSignature);
expect(start, `expected to find "${fnSignature}" in App.vue`).toBeGreaterThanOrEqual(0);
const closingParen = appSource.indexOf(")", start);
const searchStart = closingParen >= 0 ? closingParen : start;
const braceStart = appSource.indexOf("{", searchStart);
let depth = 0;
for (let i = braceStart; i < appSource.length; i++) {
if (appSource[i] === "{") depth++;
else if (appSource[i] === "}") {
depth--;
if (depth === 0) return appSource.slice(braceStart, i + 1);
}
}
throw new Error(`unbalanced braces reading body of "${fnSignature}"`);
}
describe("new query entry context wiring", () => {
it("does not keep a sticky context source that can outrank the active tab", () => {
expect(appSource).not.toContain("newQueryContextSource");
});
it("resolves the shared new-query entry from the active tab and unique online SQL connection", () => {
const body = bodyOf("async function newQuery");
expect(body).toContain('quickConnectionOpenTarget(connection).kind === "query"');
expect(body).toContain("connectedSqlConnectionIds,");
expect(body).toContain("preferredSource: options.preferredSource");
});
it("creates an unbound editor for manual SQL connection selection when the target is ambiguous", () => {
const body = bodyOf("async function newQuery");
expect(body).toContain("if (sqlConnections.length > 0)");
expect(body).toContain('queryStore.createTab("", "", undefined, "query")');
});
it("keeps the editor-group entry focused on its own group and tab context", () => {
const body = bodyOf("newQuery: (groupId: string) =>");
expect(body).toContain("queryStore.focusGroup(groupId)");
expect(body).toContain('preferredSource: "tab"');
});
it("routes keyboard shortcut with sidebar context when the sidebar is focused (#11801)", () => {
const body = bodyOf("async function handleKeydown");
expect(body).toContain('sidebarFocused ? "sidebar" : undefined');
});
it("routes the app toolbar through the last focused sidebar context (#11791)", () => {
const toolbar = appSource.slice(appSource.indexOf("<AppToolbar"), appSource.indexOf("/>", appSource.indexOf("<AppToolbar")));
expect(toolbar).toContain(`@new-query="newQuery({ preferredSource: lastFocusedSidebarSurface ? 'sidebar' : undefined })"`);
});
it("opens the database default query for Cmd+Enter from the sidebar", () => {
const body = bodyOf("async function handleKeydown");
expect(body).toContain("isExecuteSqlShortcut(e, shortcuts) && defaultQueryShortcutTarget");
expect(body).toContain('newQuery({ preferredSource: "sidebar", openDefault: true })');
expect(bodyOf("async function newQuery")).toContain("queryStore.openDefaultQuery");
});
it("clears lastFocusedSidebarSurface when editor content is clicked", () => {
expect(appSource).toContain("rememberEditorContentSurface");
});
});
@@ -1,4 +1,3 @@
import { readFileSync } from "node:fs";
import zhCN from "@/i18n/locales/zh-CN";
import { createI18n } from "vue-i18n";
import { describe, expect, it, vi } from "vitest";
@@ -120,17 +119,4 @@ describe("MySQL auto-increment warning translations", () => {
expect(translateMysqlAutoIncrementWarning(t, `AUTO_INCREMENT column "${column}" requires a supporting index (first key column for InnoDB).`)).toBe(`自增字段“${column}”必须有有效索引;使用 InnoDB 时,该字段必须位于索引首列。`);
expect(translateMysqlAutoIncrementWarning(t, "Unknown backend warning")).toBe("Unknown backend warning");
});
it("defines all warning messages and matching placeholders in every registered locale", () => {
const registry = readFileSync("apps/desktop/src/i18n/index.ts", "utf8");
const locales = [...registry.match(/const supportedLocales: Locale\[\] = \[(.*?)\]/)![1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
expect(locales.length).toBeGreaterThan(0);
for (const locale of locales) {
const source = readFileSync(`apps/desktop/src/i18n/locales/${locale}.ts`, "utf8");
for (const key of ["mysqlAutoIncrementSingleColumn", "mysqlAutoIncrementRequiresIndex", "mysqlAutoIncrementRequiresNativeColumn"]) {
const line = source.split("\n").find((line) => line.trimStart().startsWith(`${key}:`));
expect(line, `${locale}: ${key}`).toBeDefined();
expect(line!.match(/\{[^}]+\}/g) ?? []).toEqual(key === "mysqlAutoIncrementRequiresIndex" ? ["{column}"] : []);
}
}
});
});
@@ -1,38 +1,12 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8");
const redisBrowserSource = readFileSync(new URL("../../../components/redis/RedisKeyBrowser.vue", import.meta.url), "utf8");
describe("AI Redis console routing", () => {
it("refuses a Redis command rather than moving the workspace to the bound console", () => {
// "An AI action never moves the workspace" is the point of #9902. The bound
// console is driven only when it is already the visible tab; otherwise the
// user is told to open it. No tab is switched and none is created.
const start = appSource.indexOf("function routeAiRedisCommand");
const route = appSource.slice(start, appSource.indexOf("function aiTargetTabSql", start));
expect(route).not.toContain("switchTab");
expect(route).not.toContain("createTab");
expect(route).not.toContain("ensureRedisConsoleTab");
expect(appSource).not.toContain("ensureRedisConsoleTab");
expect(route).toContain("void deliverRedisAiCommand(command, execute, target);");
// The refusal is reported, not silent.
expect(appSource).toContain('toast(t("ai.redisConsoleUnreachable"), 5000);');
});
it("uses the console safety path and rejects unavailable command input", () => {
it("uses the console safety path", () => {
expect(classifyRedisCommandSafety("CONFIG SET requirepass secret")).toBe("blocked");
expect(classifyRedisCommandSafety("FLUSHDB")).toBe("blocked");
expect(classifyRedisCommandSafety("SET issue:846 fixed")).toBe("write");
expect(classifyRedisCommandSafety("INFO server")).toBe("allowed");
expect(redisBrowserSource).toContain("if (!normalizedCommand || commandRunning.value) return false;");
expect(redisBrowserSource).toContain("await executeCommand();");
expect(redisBrowserSource).not.toMatch(/async function executeAiCommand[\s\S]*?await runRedisCommand\(command\)/);
expect(contentAreaSource).toContain("?? false");
});
// Regression for review feedback: an unknown command inside a multi-line
@@ -1,36 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { BACKGROUND_IMAGE_SURFACE_VARS } from "@/lib/app/appBackgroundImage";
/**
* While a wallpaper is active the app republishes `--background` (and the other
* surface tokens) with the configured alpha, so a dialog that paints itself with
* `bg-background` turns translucent and its text becomes hard to read. These
* dialogs used to override the opaque `bg-popover` surface with `!bg-background`,
* so they are pinned to `bg-background-solid` here.
*/
const DIALOG_SOURCES = {
"codeSnapshot/CodeSnapshotDialog.vue": "../../../components/codeSnapshot/CodeSnapshotDialog.vue",
"grid/GridSnapshotDialog.vue": "../../../components/grid/GridSnapshotDialog.vue",
"editor/DelimitedListDialog.vue": "../../../components/editor/DelimitedListDialog.vue",
"editor/SqlParameterDialog.vue": "../../../components/editor/SqlParameterDialog.vue",
"objects/ProcedureExecutionDialog.vue": "../../../components/objects/ProcedureExecutionDialog.vue",
} as const;
describe("dialog surfaces under a wallpaper", () => {
it("keeps --background in the translucent wallpaper surface set", () => {
expect(BACKGROUND_IMAGE_SURFACE_VARS).toContain("--background");
});
for (const [label, relativePath] of Object.entries(DIALOG_SOURCES)) {
it(`${label} paints an opaque surface`, () => {
const source = readFileSync(new URL(relativePath, import.meta.url), "utf8");
const dialogContentClasses = Array.from(source.matchAll(/<DialogContent\b[^>]*class="([^"]*)"/g), (match) => match[1]);
expect(dialogContentClasses.length).toBeGreaterThan(0);
for (const classes of dialogContentClasses) {
expect(classes).toContain("bg-background-solid");
expect(classes).not.toMatch(/(^|\s)!bg-background(\s|$)/);
}
});
}
});