mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
test: remove source-text assertion specs
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
-171
@@ -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 = /);
|
||||
});
|
||||
});
|
||||
-195
@@ -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");
|
||||
});
|
||||
});
|
||||
-47
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
-25
@@ -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"');
|
||||
});
|
||||
});
|
||||
-36
@@ -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");
|
||||
});
|
||||
});
|
||||
Vendored
-12
@@ -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))");
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
-42
@@ -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}"`);
|
||||
});
|
||||
});
|
||||
-55
@@ -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);
|
||||
|
||||
-14
@@ -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"');
|
||||
});
|
||||
});
|
||||
-35
@@ -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();
|
||||
|
||||
-17
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
-32
@@ -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>/);
|
||||
});
|
||||
});
|
||||
-32
@@ -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");
|
||||
});
|
||||
});
|
||||
-10
@@ -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 = {
|
||||
|
||||
-42
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
-15
@@ -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|>)/);
|
||||
}
|
||||
});
|
||||
});
|
||||
-10
@@ -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*\}/);
|
||||
});
|
||||
});
|
||||
apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.elasticsearchClearGuard.spec.ts
Vendored
-64
@@ -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,");
|
||||
});
|
||||
});
|
||||
-34
@@ -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");
|
||||
|
||||
Vendored
-43
@@ -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("经典");
|
||||
});
|
||||
});
|
||||
-18
@@ -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|$)/);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user