mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
test: drop source-text assertion tests and merge locale checks
This commit is contained in:
@@ -1,58 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const appSource = readFileSync(new URL("../App.vue", import.meta.url), "utf8");
|
||||
const sidebarSource = readFileSync(new URL("../components/layout/AppSidebar.vue", import.meta.url), "utf8");
|
||||
const connectionTreeSource = readFileSync(new URL("../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
|
||||
const treeItemSource = readFileSync(new URL("../components/sidebar/TreeItem.vue", import.meta.url), "utf8");
|
||||
const contentAreaSource = readFileSync(new URL("../components/layout/ContentArea.vue", import.meta.url), "utf8");
|
||||
const dataGridSource = readFileSync(new URL("../components/grid/DataGrid.vue", import.meta.url), "utf8");
|
||||
const objectBrowserSource = readFileSync(new URL("../components/objects/ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
const aiAssistantSource = readFileSync(new URL("../components/editor/AiAssistant.vue", import.meta.url), "utf8");
|
||||
const settingsSource = readFileSync(new URL("../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
const sqlLibrarySource = readFileSync(new URL("../components/layout/SqlLibraryPanel.vue", import.meta.url), "utf8");
|
||||
const historySource = readFileSync(new URL("../components/editor/QueryHistory.vue", import.meta.url), "utf8");
|
||||
const driverStoreSource = readFileSync(new URL("../components/config/DriverStoreDialog.vue", import.meta.url), "utf8");
|
||||
const pluginCenterSource = readFileSync(new URL("../components/plugins/PluginContributionsPanel.vue", import.meta.url), "utf8");
|
||||
const connectionDialogSource = readFileSync(new URL("../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("search shortcut focus routing", () => {
|
||||
it("prioritizes the sidebar when Ctrl+F originates in the navigation area", () => {
|
||||
expect(appSource).toMatch(/const target = e\.target instanceof Element \? e\.target : null;[\s\S]*target\?\.closest\("\[data-app-sidebar\]"\)[\s\S]*appSidebarRef\.value\?\.focusSearch\(target\)/);
|
||||
expect(sidebarSource).toContain("<div data-app-sidebar");
|
||||
});
|
||||
|
||||
it("routes a focused sidebar table-search control to its own input", () => {
|
||||
expect(connectionTreeSource).toMatch(/function focusSearch\(target: Element \| null = null\)[\s\S]*data-sidebar-table-search-control[\s\S]*data-sidebar-table-search-parent-id/);
|
||||
expect(treeItemSource).toMatch(/function onTableSearchControlKeydown\(event: KeyboardEvent\)[\s\S]*isFocusSearchShortcut\(event, settingsStore\.editorSettings\.shortcuts\)[\s\S]*data-sidebar-table-search-parent-id/);
|
||||
expect(treeItemSource).toContain("data-sidebar-table-search-control");
|
||||
});
|
||||
|
||||
it("routes a focused table properties panel to its local search input", () => {
|
||||
expect(contentAreaSource).toMatch(/function focusSearch\(target: Element \| null = null\)[\s\S]*dataGridRef\.value\?\.focusSearch\(target\)/);
|
||||
expect(dataGridSource).toMatch(/function focusSearch\(target: Element \| null = null\)[\s\S]*data-table-info-drawer[\s\S]*data-table-info-search/);
|
||||
expect(dataGridSource).toMatch(/focusSearch\(event\.target instanceof Element \? event\.target : document\.activeElement instanceof Element \? document\.activeElement : null\);/);
|
||||
expect(objectBrowserSource).toMatch(/function focusSearch\(target: Element \| null = null\)[\s\S]*data-object-table-info-panel[\s\S]*data-table-info-search/);
|
||||
});
|
||||
|
||||
it("routes auxiliary page and panel searches before the query surface", () => {
|
||||
expect(appSource).toMatch(/function focusSearchInAuxiliarySurface\(target: Element \| null\)[\s\S]*data-connection-db-search[\s\S]*data-settings-global-search[\s\S]*data-driver-store-agent-search[\s\S]*data-plugin-marketplace-search[\s\S]*data-history-panel[\s\S]*data-sql-library-panel/);
|
||||
expect(appSource).toMatch(/lastFocusedAuxiliarySurface[\s\S]*rememberAuxiliarySearchSurface[\s\S]*data-sql-library-panel/);
|
||||
expect(appSource).toMatch(/const targetIsDocument = !target \|\| target === document\.body \|\| target === document\.documentElement;[\s\S]*if \(!targetIsDocument\) return false;/);
|
||||
expect(appSource).toMatch(/lastFocusedSidebarSurface[\s\S]*rememberSidebarSearchSurface[\s\S]*data-app-sidebar/);
|
||||
expect(appSource).toMatch(/function handleAuxiliarySearchKeydownCapture\(e: KeyboardEvent\)[\s\S]*focusSearchInAuxiliarySurface\(target\)[\s\S]*addEventListener\("keydown", handleAuxiliarySearchKeydownCapture, true\)/);
|
||||
expect(appSource).toContain("aiAssistantRef.value.focusSearch()");
|
||||
expect(aiAssistantSource).toMatch(/function focusSearch\(\): boolean[\s\S]*setConversationListOpen\(true\)/);
|
||||
expect(aiAssistantSource).toContain("data-ai-conversation-search");
|
||||
expect(settingsSource).toContain("data-settings-global-search");
|
||||
expect(sqlLibrarySource).toContain("data-sql-library-search");
|
||||
expect(historySource).toContain("data-history-search");
|
||||
expect(driverStoreSource).toContain("data-driver-store-agent-search");
|
||||
expect(pluginCenterSource).toContain("data-plugin-marketplace-search");
|
||||
expect(connectionDialogSource).toContain("data-connection-db-search");
|
||||
});
|
||||
|
||||
it("keeps the search shortcut inside the modal connection dialog", () => {
|
||||
expect(appSource).toMatch(/if \(showConnectionDialog\.value\) \{[\s\S]*?data-connection-db-search[\s\S]*?return true;\s*\}/);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const rendererSource = readFileSync(new URL("../AiChartRenderer.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("AiChartRenderer layout", () => {
|
||||
it("establishes the chart height on the parent that vue-echarts fills", () => {
|
||||
expect(rendererSource).toContain("min-h-60 h-[clamp(15rem,35vw,20rem)]");
|
||||
expect(rendererSource).toContain('class="min-h-0 flex-1"');
|
||||
expect(rendererSource).toContain('class="h-full w-full p-2"');
|
||||
expect(rendererSource).not.toContain('class="h-80 w-full p-2"');
|
||||
});
|
||||
|
||||
it("uses the native save dialog and binary writer for PNGs in Tauri", () => {
|
||||
expect(rendererSource).toContain('await import("@tauri-apps/plugin-dialog")');
|
||||
expect(rendererSource).toContain('await import("@tauri-apps/plugin-fs")');
|
||||
expect(rendererSource).toContain("await writeFile(path, new Uint8Array(await blob.arrayBuffer()))");
|
||||
});
|
||||
|
||||
it("surfaces download failures through a localized toast instead of a hardcoded English tooltip", () => {
|
||||
expect(rendererSource).toContain("t('ai.chartDownloadPng')");
|
||||
expect(rendererSource).toContain('toast(t("ai.chartDownloadFailed"))');
|
||||
const savePath = rendererSource.slice(rendererSource.indexOf("await writeFile(path, new Uint8Array(await blob.arrayBuffer()))"));
|
||||
expect(savePath).toContain('} catch {\n toast(t("ai.chartDownloadFailed"));\n }');
|
||||
expect(rendererSource).not.toContain('title="Download PNG"');
|
||||
expect(rendererSource).not.toContain('aria-label="Download PNG"');
|
||||
expect(rendererSource).toContain('t("ai.chartData")');
|
||||
});
|
||||
});
|
||||
@@ -3,77 +3,14 @@ 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 overview = readFileSync(new URL("../ConsulOverview.vue", import.meta.url), "utf8");
|
||||
const sessions = readFileSync(new URL("../ConsulSessions.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 workspace = readFileSync(new URL("../ConsulWorkspace.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("watches both service and node Catalog resources and cancels them on teardown", () => {
|
||||
for (const target of ["catalogServices", "catalogServiceNodes", "catalogNodes", "catalogNodeServices"]) {
|
||||
expect(services).toContain(`kind: "${target}"`);
|
||||
}
|
||||
expect(services).toMatch(/onBeforeUnmount\(\(\) => \{\s*void stopWatches\(\);\s*\}\);/);
|
||||
expect(services).toContain("switchBrowseMode");
|
||||
});
|
||||
|
||||
it("supports state, node, and service Health resources with a scoped watch", () => {
|
||||
for (const target of ["healthState", "healthNode", "healthServiceInstances"]) {
|
||||
expect(health).toContain(`kind: "${target}"`);
|
||||
}
|
||||
expect(health).toContain('browseMode = ref<"state" | "node" | "service">');
|
||||
expect(health).toMatch(/onBeforeUnmount\(\(\) => \{\s*void stopWatch\(\);\s*\}\);/);
|
||||
expect(health).toContain("selectedCheck");
|
||||
expect(health).toContain("selectedCheck.Definition");
|
||||
});
|
||||
|
||||
it("filters and paginates Health results without querying on every target keystroke", () => {
|
||||
for (const state of ["browseNameDraft", "keywordFilter", "statusFilter", "typeFilter"]) {
|
||||
expect(health).toContain(`const ${state} = ref(`);
|
||||
}
|
||||
for (const collection of ["filteredChecks", "filteredLocalChecks", "pagedChecks", "pagedLocalChecks", "pagedServiceInstances"]) {
|
||||
expect(health).toContain(`const ${collection} = computed(`);
|
||||
}
|
||||
expect(health).toContain('v-model="browseNameDraft"');
|
||||
expect(health).toContain('@keydown.enter="applyBrowseTarget"');
|
||||
expect(health).toContain("t('consul.ui.searchHealthChecks')");
|
||||
expect(health).toContain("<ConsulListPagination");
|
||||
expect(health).not.toContain("watch([state, browseMode, browseName]");
|
||||
});
|
||||
|
||||
it("shows Catalog metadata and independent Overview probe states", () => {
|
||||
expect(services).toContain("item.NodeMeta");
|
||||
expect(services).toContain("item.ServiceMeta");
|
||||
expect(services).toContain("nodeServices?.Node.NodeMeta");
|
||||
for (const state of ["loading", "ready", "forbidden", "unsupported", "error"]) {
|
||||
expect(overview).toContain(`"${state}"`);
|
||||
}
|
||||
expect(overview).toContain("probeStates[key]");
|
||||
});
|
||||
|
||||
it("binds Session renew to connection and scope lifecycle and exposes lock indexes", () => {
|
||||
expect(sessions).toContain("connectionActive");
|
||||
expect(sessions).toContain("consulStore.generation");
|
||||
expect(sessions).toContain("currentScopeKey()");
|
||||
expect(sessions).toContain("clearTimeout(renewTimer)");
|
||||
expect(sessions).toContain("item.lockIndex");
|
||||
expect(sessions).toContain("session.NodeChecks");
|
||||
expect(sessions).toContain("session.ServiceChecks");
|
||||
expect(sessions).toContain("!isTtlSession(session)");
|
||||
expect(sessions).toContain("ConsulListPagination");
|
||||
expect(sessions).toContain("filteredSessions");
|
||||
expect(sessions).toContain("await inspectLockKey()");
|
||||
expect(sessions).toContain("expectedModifyIndex: lockInspection.value.modifyIndex");
|
||||
expect(sessions).toContain("heldKeys[session.ID].complete");
|
||||
expect(sessions).toContain("value.items.map(normalizeConsulSession)");
|
||||
expect(sessions).toContain("withUiTimeout(api.consulSessions(props.connectionId))");
|
||||
});
|
||||
|
||||
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}`);
|
||||
@@ -82,65 +19,6 @@ describe("Consul Catalog and Health watch views", () => {
|
||||
expect(scope).toContain("impact.unavailableResources");
|
||||
});
|
||||
|
||||
it("offers both Agent service maintenance transitions", () => {
|
||||
expect(services).toContain("maintenance(service.ID, true)");
|
||||
expect(services).toContain("maintenance(service.ID, false)");
|
||||
expect(services).toContain('t("consul.ui.enableMaintenance")');
|
||||
expect(services).toContain('t("consul.ui.disableMaintenance")');
|
||||
expect(services).toContain('v-if="canAgentWrite && !isServiceInMaintenance(service.ID)"');
|
||||
expect(services).toContain('v-else-if="canAgentWrite"');
|
||||
expect(services).toContain("t(`consul.ui.agentWriteBlocked.${reason}`)");
|
||||
expect(services).toContain("{{ agentWriteDisabledMessage }}");
|
||||
});
|
||||
|
||||
it("keeps Catalog browsing searchable and separates readable Catalog data from Agent actions", () => {
|
||||
expect(services).toContain('const browseFilter = ref("");');
|
||||
expect(services).toContain("const filteredServiceNames");
|
||||
expect(services).toContain("const filteredNodeNames");
|
||||
expect(services).toContain("const localServiceItems");
|
||||
expect(services).toContain("const filteredLocalServiceItems");
|
||||
expect(services).toContain("<Search class=");
|
||||
expect(services).toContain("selectedLocalService?.ID === service.ID");
|
||||
expect(services).toContain('<Button v-for="name in pagedServiceNames"');
|
||||
expect(services).not.toContain("services[name]?.join(', ')");
|
||||
expect(services).toContain('@click="selectService(name)"');
|
||||
expect(services).toContain('@click="selectNode(name)"');
|
||||
expect(services).toContain("Catalog browsing must remain responsive even when an Agent endpoint is slow or unavailable.");
|
||||
const loadSource = services.slice(services.indexOf("async function load() {"), services.indexOf("async function loadAgentData("));
|
||||
const catalogReady = loadSource.indexOf("loading.value = false;");
|
||||
const agentLoad = loadSource.indexOf("if (current === sequence) await loadAgentData(current);");
|
||||
expect(catalogReady).toBeGreaterThanOrEqual(0);
|
||||
expect(agentLoad).toBeGreaterThan(catalogReady);
|
||||
expect(services).toContain("t('consul.ui.searchAllServices')");
|
||||
expect(services).toContain("t('consul.ui.searchLocalServices')");
|
||||
});
|
||||
|
||||
it("loads Consul tools independently and exposes complete event and result details", () => {
|
||||
expect(tools).toContain('const activeTab = ref<ToolTab>("queries")');
|
||||
for (const loader of ["loadQueries", "loadEvents", "loadCoordinates"]) {
|
||||
expect(tools).toContain(`async function ${loader}()`);
|
||||
}
|
||||
for (const errorState of ["queryError", "eventError", "coordinateError"]) {
|
||||
expect(tools).toContain(`const ${errorState} = ref("")`);
|
||||
}
|
||||
expect(tools).not.toContain("Promise.allSettled");
|
||||
expect(tools).toContain('v-model="eventNodeFilter"');
|
||||
expect(tools).toContain('v-model="eventServiceFilter"');
|
||||
expect(tools).toContain('v-model="eventTagFilter"');
|
||||
expect(tools).toContain("queryResult.Nodes");
|
||||
expect(tools).toContain("item.Coord.Adjustment");
|
||||
expect(tools).toContain("item.Coord.Height");
|
||||
});
|
||||
|
||||
it("paginates every potentially large service collection before rendering", () => {
|
||||
for (const collection of ["pagedServiceNames", "pagedNodeNames", "pagedCatalogInstances", "pagedNodeServices", "pagedLocalServices"]) {
|
||||
expect(services).toContain(`const ${collection} = computed(() => paginateConsulItems(`);
|
||||
expect(services).toContain(`v-for="${collection === "pagedCatalogInstances" ? "item" : collection.includes("Names") ? "name" : "service"} in ${collection}"`);
|
||||
}
|
||||
expect(services).toContain("<ConsulListPagination");
|
||||
expect(services).toContain(':page-size="CONSUL_LIST_PAGE_SIZE"');
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -151,82 +29,12 @@ describe("Consul Catalog and Health watch views", () => {
|
||||
expect(health).toContain("consulAgentWriteTargetSafe(store.getConfig(props.connectionId), identity.value?.node)");
|
||||
});
|
||||
|
||||
it("supports exact-Key and prefix KV watches and exposes batch atomicity", () => {
|
||||
expect(keyBrowser).toContain('watchMode = ref<"key" | "prefix">("prefix")');
|
||||
expect(keyBrowser).toContain('prefix: watchMode.value === "prefix"');
|
||||
expect(keyBrowser).toContain(':on-watch-key="watchSelectedKey"');
|
||||
expect(keyBrowser).toContain("transferReport.atomic");
|
||||
expect(keyBrowser).toContain("deleteReport.atomic");
|
||||
expect(keyBrowser).toContain("row.opIndex");
|
||||
});
|
||||
|
||||
it("keeps the Key prefix search first and groups enhanced Consul KV actions after a divider", () => {
|
||||
expect(keyBrowser).toContain("<template #toolbar-trailing>");
|
||||
expect(keyBrowser).not.toContain('v-model="watchPrefix" class="h-7 w-52 text-xs"');
|
||||
expect(kvBrowser).toContain('<slot name="toolbar-trailing" />');
|
||||
expect(kvBrowser).toContain('class="mx-1 h-6 w-px shrink-0 bg-border"');
|
||||
});
|
||||
|
||||
it("keeps exact-Key watch start and stop beside the selected Key", () => {
|
||||
expect(keyBrowser).toContain(":watch-active-key=\"watchRunning && watchMode === 'key' ? watchPrefix : null\"");
|
||||
expect(keyBrowser).toContain('watchMode.value === "key" && watchPrefix.value === route.key');
|
||||
expect(keyBrowser).toContain("await stopWatch();\n return;");
|
||||
});
|
||||
|
||||
it("serializes search progress polling and invalidates stale pollers", () => {
|
||||
expect(keyBrowser).toContain("searchProgressSequence");
|
||||
expect(keyBrowser).toContain("searchProgressTimer = setTimeout");
|
||||
expect(keyBrowser).toContain("sequence === searchProgressSequence");
|
||||
expect(keyBrowser).not.toContain("searchProgressTimer = setInterval");
|
||||
});
|
||||
|
||||
it("uses a compact Session-protection card and a metadata grid for Consul KV details", () => {
|
||||
expect(kvBrowser).toContain('labels.sessionProtected || "Session protected"');
|
||||
expect(kvBrowser).toContain('labels.sessionProtectedHint || "Editing and deletion are disabled to protect this distributed lock."');
|
||||
expect(kvBrowser).toContain("copyText(selectedMetadata?.session)");
|
||||
expect(kvBrowser).toContain('<dl class="grid grid-cols-2 divide-x divide-y sm:grid-cols-3">');
|
||||
expect(kvBrowser).not.toContain('["Session", metadata?.session]');
|
||||
});
|
||||
|
||||
it("exports the current Consul full-text search result set", () => {
|
||||
expect(keyBrowser).toContain("async function exportSearchResults()");
|
||||
expect(keyBrowser).toContain('format: "dbx-consul-kv-search-results"');
|
||||
expect(keyBrowser).toContain('t("consul.tools.exportSearchResults")');
|
||||
});
|
||||
|
||||
it("carries full-text match metadata into the selected Consul KV detail", () => {
|
||||
expect(keyBrowser).toContain("const searchHighlight = ref<{");
|
||||
expect(keyBrowser).toContain("matchesKey: result.matchesKey");
|
||||
expect(keyBrowser).toContain(':search-highlight="searchHighlight"');
|
||||
});
|
||||
|
||||
it("offers the same server-backed prefix completion for export, migration, and prefix deletion", () => {
|
||||
expect(keyBrowser).toContain('type PrefixSuggestionTarget = "export" | "migration" | "delete"');
|
||||
expect(keyBrowser).toContain("const prefixSuggestionDebounceMs = 300;");
|
||||
expect(keyBrowser).toContain("function schedulePrefixSuggestions(target: PrefixSuggestionTarget, value: string)");
|
||||
expect(keyBrowser).toContain("schedulePrefixSuggestions(target, suggestion.key);");
|
||||
expect(keyBrowser).toContain('event.key !== "Enter" && event.key !== "Tab"');
|
||||
expect(keyBrowser).toContain("prefixSuggestionIndex.value >= 0 ? prefixSuggestionIndex.value : 0");
|
||||
expect(keyBrowser).toContain("@input=\"schedulePrefixSuggestions('export'");
|
||||
expect(keyBrowser).toContain("@input=\"schedulePrefixSuggestions('migration'");
|
||||
expect(keyBrowser).toContain("@input=\"schedulePrefixSuggestions('delete'");
|
||||
expect(keyBrowser).toContain("@keydown=\"onPrefixSuggestionKeydown('migration', $event)\"");
|
||||
expect(keyBrowser).toContain("@keydown=\"onPrefixSuggestionKeydown('delete', $event)\"");
|
||||
expect(keyBrowser).toContain('<PopoverContent align="start" side="bottom" :collision-padding="12"');
|
||||
expect(keyBrowser).toContain("max-h-[var(--reka-popover-content-available-height)]");
|
||||
});
|
||||
|
||||
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("shows the server-provided reason when a prefix-delete operation fails", () => {
|
||||
expect(keyBrowser).toContain("'message' in row && row.message");
|
||||
expect(keyBrowser).toContain(':title="row.message"');
|
||||
});
|
||||
|
||||
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 = "")');
|
||||
@@ -243,33 +51,4 @@ describe("Consul Catalog and Health watch views", () => {
|
||||
expect(operator).toContain("keyringWriteVisible");
|
||||
expect(operator).toContain("props.capabilities?.operatorKeyring");
|
||||
});
|
||||
|
||||
it("keeps workspace actions fixed while only the tab strip scrolls horizontally", () => {
|
||||
expect(workspace).toContain("min-w-0 flex-1 overflow-x-auto overflow-y-hidden");
|
||||
expect(workspace).toContain("h-8 w-max min-w-max justify-start");
|
||||
expect(workspace).toContain('class="h-7 flex-none gap-1.5 px-3 text-xs"');
|
||||
expect(workspace).toContain("flex h-11 shrink-0 items-center gap-2 border-b px-3");
|
||||
expect(workspace).toContain('class="flex shrink-0 items-center gap-1"');
|
||||
});
|
||||
|
||||
it("starts with KV and keeps the cluster overview as a sidebar surface", () => {
|
||||
expect(workspace).toContain('const activeTab = ref<WorkspaceTab>(restoredUiState.activeTab ?? "kv");');
|
||||
expect(workspace).not.toContain('<TabsTrigger value="overview"');
|
||||
expect(workspace).not.toContain('<TabsContent value="overview"');
|
||||
});
|
||||
|
||||
it("shows the real datacenter by default and gates advanced scope controls by capability", () => {
|
||||
expect(workspace).toContain("const resolvedDatacenter = computed(() => activeScope.value.datacenter || capabilities.value?.datacenter");
|
||||
expect(workspace).toContain('const enterpriseScopeAvailable = computed(() => capabilities.value?.namespaces === "supported" || capabilities.value?.partitions === "supported");');
|
||||
expect(workspace).toContain('v-if="enterpriseScopeAvailable"');
|
||||
expect(workspace).toContain('v-if="datacenterOptions.length > 1" v-model="scopeDraft.datacenter"');
|
||||
});
|
||||
|
||||
it("hides only disabled ACLs and Enterprise-only scope tabs", () => {
|
||||
expect(workspace).toContain('const aclVisible = computed(() => capabilities.value?.acl !== "disabled");');
|
||||
expect(workspace).toContain("const scopeTabVisible = computed(() => enterpriseScopeAvailable.value);");
|
||||
expect(workspace).toContain('v-if="aclVisible" value="acl"');
|
||||
expect(workspace).toContain('v-if="scopeTabVisible" value="scope"');
|
||||
expect(workspace).toContain('if (activeTab.value === "acl" && !aclVisible.value) activeTab.value = "kv";');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../SchemaDiffDialog.vue", import.meta.url), "utf8");
|
||||
const sessionSource = readFileSync(new URL("../../../composables/useSchemaDiffSession.ts", import.meta.url), "utf8");
|
||||
const configStepSource = readFileSync(new URL("../SchemaDiffConfigStep.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
it("fits the dialog to its portal instead of the viewport width", () => {
|
||||
expect(dialogSource).toContain('width: "100%"');
|
||||
expect(dialogSource).toContain('height: "100%"');
|
||||
expect(dialogSource).not.toContain('width: "100vw"');
|
||||
expect(dialogSource).not.toContain('height: "100vh"');
|
||||
});
|
||||
|
||||
it("removes the normal dialog gutter and minimum width while maximized", () => {
|
||||
expect(dialogSource).toContain(":portal-class=\"isMaximized ? 'p-0' : undefined\"");
|
||||
expect(dialogSource).toContain("isMaximized ? 'min-w-0' : 'min-w-[800px] resize'");
|
||||
});
|
||||
|
||||
it("closes the options panel before allowing Escape to dismiss the dialog", () => {
|
||||
expect(dialogSource).toContain('@escape-key-down="handleDialogEscape"');
|
||||
expect(dialogSource).toContain("if (!showOptionsPanel.value) return;");
|
||||
expect(dialogSource).toContain("event.preventDefault();");
|
||||
expect(dialogSource).toContain("showOptionsPanel.value = false;");
|
||||
});
|
||||
|
||||
it("lets the deploy confirm dialog body shrink so a long destructive statement can't push the footer buttons off-screen", () => {
|
||||
expect(dialogSource).toContain('<div class="py-2 space-y-3 min-w-0">');
|
||||
});
|
||||
|
||||
it("lets the config step scroll vertically so the Compare button stays reachable when 'compare specific tables' is enabled", () => {
|
||||
// Regression for #6627: the "compare specific tables" feature (#6533/#6540) grows the
|
||||
// config step (table multi-select + same-name match). Under a fixed-height dialog that
|
||||
// step used to be clipped (`overflow-hidden`), pushing the Compare button below the
|
||||
// visible area with no way to scroll to it. The container must scroll on all steps
|
||||
// except the result step, which relies on splitpanes to manage its own height.
|
||||
expect(dialogSource).toContain("step === 'result' ? 'overflow-hidden' : 'overflow-y-auto'");
|
||||
// The config step must not be flex-shrunk (which would swallow its height and suppress
|
||||
// the scrollbar); only then does the taller-than-dialog content actually overflow and
|
||||
// become reachable via scrolling.
|
||||
expect(dialogSource).toMatch(/<SchemaDiffConfigStep\n[\s\S]*?class="shrink-0"/);
|
||||
});
|
||||
|
||||
it("clears stale result and progress state whenever a new dialog session opens", () => {
|
||||
expect(dialogSource).toContain("function resetComparisonResultState() {");
|
||||
expect(dialogSource).toMatch(/if \(!isOpen\) return;[\s\S]*?resetComparisonResultState\(\);/);
|
||||
expect(dialogSource).toContain("diffObjects.value = [];");
|
||||
expect(dialogSource).toContain("lastDiffResult.value = null;");
|
||||
expect(dialogSource).toContain("schemaDiffProgress.value = null;");
|
||||
});
|
||||
|
||||
it("shows real metadata progress and indeterminate comparison phases without fabricated percentages", () => {
|
||||
expect(sessionSource).toContain('phase: "loading-table-lists"');
|
||||
expect(sessionSource).toContain('phase: "loading-source-details"');
|
||||
expect(sessionSource).toContain('phase: "loading-target-details"');
|
||||
expect(sessionSource).toContain('phase: "loading-extra-objects"');
|
||||
expect(sessionSource).toContain('phase: "comparing"');
|
||||
expect(sessionSource).toContain('phase: "generating"');
|
||||
expect(sessionSource).toContain("onProgress: (progress) =>");
|
||||
expect(dialogSource).toContain("schemaDiffProgressCount");
|
||||
expect(dialogSource).toContain('role="progressbar"');
|
||||
expect(dialogSource).toContain("schema-diff-progress-indeterminate");
|
||||
expect(dialogSource).not.toMatch(/\b(?:20|40|60|80)%/);
|
||||
});
|
||||
|
||||
it("renders a localized next-step label alongside the current progress state", () => {
|
||||
expect(dialogSource).toContain("getSchemaDiffNextProgressStep");
|
||||
expect(dialogSource).toContain("schemaDiffNextProgressLabel");
|
||||
expect(dialogSource).toContain('t("diff.progress.next"');
|
||||
expect(dialogSource).toContain('v-if="schemaDiffNextProgressLabel"');
|
||||
expect(dialogSource).toContain("shouldLoadSchemaDiffExtraObjectPhase");
|
||||
});
|
||||
|
||||
it("clears progress before preserving the existing comparison error flow", () => {
|
||||
expect(sessionSource).toContain("session.error = message;");
|
||||
expect(sessionSource).toContain('session.status = "failed";');
|
||||
expect(dialogSource).toContain("toast(session.error, 5000);");
|
||||
expect(dialogSource).toContain('step.value = "config";');
|
||||
});
|
||||
|
||||
it("keeps an active comparison running after the dialog unmounts", () => {
|
||||
expect(dialogSource).toContain("const session = startSchemaDiffSession(");
|
||||
expect(dialogSource).toContain("onBeforeUnmount(() => {");
|
||||
expect(dialogSource).toContain("componentUnmounted = true;");
|
||||
expect(dialogSource).not.toContain("comparisonRequestId");
|
||||
});
|
||||
|
||||
it("does not restore persisted table matches before source and target identities are ready", () => {
|
||||
expect(configStepSource).toContain('if (!isTableIdentityReady("source")) {');
|
||||
expect(configStepSource).toContain("clearUnavailableTableSelection();");
|
||||
expect(configStepSource).toContain('v-if="restrictTables && canConfigureTableSelection"');
|
||||
expect(configStepSource).toContain("restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')");
|
||||
});
|
||||
|
||||
it("resets stale target schema and object selection when the target identity changes", () => {
|
||||
expect(dialogSource).toContain("suppressTargetIdentityReset");
|
||||
expect(dialogSource).toContain("clearCompareObjectSelection");
|
||||
expect(dialogSource).toContain("runWithoutTargetIdentityReset");
|
||||
expect(dialogSource).toContain("watch(targetConnectionId,");
|
||||
expect(dialogSource).toContain("watch(targetDatabase,");
|
||||
expect(dialogSource).toContain("watch(targetDbType,");
|
||||
expect(dialogSource).toContain('targetDatabase.value = ""');
|
||||
expect(dialogSource).toContain('targetSchema.value = ""');
|
||||
expect(dialogSource).toContain("isSchemaAware(dbType as DatabaseType)");
|
||||
expect(dialogSource).toContain("selectedTables: undefined");
|
||||
expect(dialogSource).toContain("selectedRoutines: undefined");
|
||||
});
|
||||
|
||||
it("gates config table-list loading on the tables compare switch", () => {
|
||||
expect(configStepSource).toContain("tablesCompareEnabled.value && shouldLoadSchemaDiffTableList");
|
||||
});
|
||||
|
||||
it("keeps explicit table selection in a shared scope below both sides", () => {
|
||||
const comparisonScope = configStepSource.indexOf("<!-- Comparison Scope -->");
|
||||
const targetInfo = configStepSource.indexOf("<!-- Target Info -->");
|
||||
const options = configStepSource.indexOf("<!-- Options -->");
|
||||
const tableSelectors = configStepSource.match(/<TableMultiSelect\b/g) ?? [];
|
||||
const tableSelector = configStepSource.indexOf("<TableMultiSelect", comparisonScope);
|
||||
const targetMatch = configStepSource.indexOf("<!-- Target Same-Name Match -->", comparisonScope);
|
||||
|
||||
expect(comparisonScope).toBeGreaterThan(targetInfo);
|
||||
expect(comparisonScope).toBeLessThan(options);
|
||||
expect(tableSelectors).toHaveLength(2);
|
||||
expect(tableSelector).toBeGreaterThan(comparisonScope);
|
||||
expect(tableSelector).toBeLessThan(options);
|
||||
expect(targetMatch).toBeGreaterThan(comparisonScope);
|
||||
expect(targetMatch).toBeLessThan(options);
|
||||
});
|
||||
|
||||
it("wires source-to-target table mappings through the selector and compare request", () => {
|
||||
expect(configStepSource).toContain('@update:model-value="(value: string) => handleTableMappingUpdate(match.sourceTable, value)"');
|
||||
expect(configStepSource).toContain("tableMatchStatus.${match.kind}");
|
||||
expect(dialogSource).toContain('@update:table-mappings="handleTableMappingsUpdate"');
|
||||
expect(sessionSource).toContain("tableMappings: sessionOptions.selectedTables === undefined ? undefined : sessionOptions.tableMappings");
|
||||
expect(sessionSource).toContain("ignoreTableNameCase: sessionOptions.ignoreTableNameCase");
|
||||
expect(sessionSource).toContain("ignoreColumnNameCase: sessionOptions.ignoreColumnNameCase");
|
||||
expect(dialogSource).toContain("const swappedMappings = swapSchemaDiffTableMappings(currentOptions.tableMappings ?? []);");
|
||||
expect(dialogSource).toContain('value="routines"');
|
||||
expect(configStepSource).toContain("handleUpdateSelectedRoutines");
|
||||
});
|
||||
|
||||
it("exposes table and routine compare switches on the config step", () => {
|
||||
expect(configStepSource).toContain('import { Switch } from "@/components/ui/switch"');
|
||||
expect(configStepSource).toContain("update:compareScope");
|
||||
expect(configStepSource).toContain("handleTablesCompareEnabled");
|
||||
expect(configStepSource).toContain("handleRoutinesCompareEnabled");
|
||||
expect(configStepSource).toContain("effectiveRoutinesEnabled");
|
||||
expect(configStepSource).toContain("const routinesEnabled = effectiveRoutinesEnabled.value");
|
||||
expect(configStepSource).toContain("if (!tablesEnabled && !routinesEnabled) return false");
|
||||
expect(configStepSource).toContain("unrestrictedRoutineLoadTooLarge");
|
||||
expect(configStepSource).toContain("SCHEMA_DIFF_UNRESTRICTED_ROUTINE_LIMIT");
|
||||
expect(configStepSource).toContain('t("diff.routineSelectionTooLarge"');
|
||||
expect(configStepSource).toContain('t("diff.tableCompareDisabled")');
|
||||
expect(configStepSource).toContain('t("diff.routineCompareDisabled")');
|
||||
expect(configStepSource).toContain("schemaDiffRoutineObjectTypesIntersection");
|
||||
expect(configStepSource).toContain('t("diff.routineSameNameMatchingOnly")');
|
||||
expect(dialogSource).toContain('@update:compare-scope="handleCompareScopeUpdate"');
|
||||
expect(dialogSource).toContain("function handleCompareScopeUpdate");
|
||||
expect(dialogSource).toContain("const routinesCompareEnabled = computed(() => !!schemaDiffPanelOptions.value.functions)");
|
||||
expect(dialogSource).toContain("effectiveRoutinesEnabled");
|
||||
expect(dialogSource).not.toContain("functions: value === undefined ? activeConfig.value.options.functions : true");
|
||||
expect(sessionSource).toContain("shouldLoadSchemaDiffRoutines");
|
||||
expect(sessionSource).toContain("shouldLoadSchemaDiffExtraObjectPhase");
|
||||
expect(sessionSource).toContain("const loadTableMetadata = !!(sessionOptions.tables || sessionOptions.views)");
|
||||
expect(sessionSource).not.toContain("options.selectedRoutines !== undefined");
|
||||
expect(sessionSource).not.toContain("wantRoutines");
|
||||
});
|
||||
|
||||
it("shows result tabs only for enabled compare scopes", () => {
|
||||
expect(dialogSource).toContain("showTableResultTab");
|
||||
expect(dialogSource).toContain("showRoutineResultTab");
|
||||
expect(dialogSource).toContain("showResultTabList");
|
||||
expect(dialogSource).toContain("tablesCompareEnabled");
|
||||
expect(dialogSource).toContain("routinesCompareEnabled");
|
||||
expect(dialogSource).toContain("effectiveRoutinesEnabled");
|
||||
expect(dialogSource).toContain("showNoDifferences");
|
||||
expect(dialogSource).not.toContain("showRoutinesNotCompared");
|
||||
expect(dialogSource).toContain("showRoutineNoDifferences");
|
||||
expect(dialogSource).toContain('t("diff.resultTabTables"');
|
||||
expect(dialogSource).toContain('t("diff.resultTabRoutines"');
|
||||
expect(dialogSource).toContain('t("diff.noDifferences")');
|
||||
expect(dialogSource).toContain("schemaDiffRoutineObjectTypesIntersection");
|
||||
expect(dialogSource).toContain('v-if="showResultTabList"');
|
||||
expect(dialogSource).toContain('v-if="showTableResultTab"');
|
||||
expect(dialogSource).toContain("data-[state=inactive]:hidden");
|
||||
expect(dialogSource).toContain("if (!showTableResultTab.value && showRoutineResultTab.value)");
|
||||
expect(dialogSource).toContain("loadObjectSourceWithRoutineFallback");
|
||||
});
|
||||
|
||||
it("defers routine deploy except for postgres-family targets", () => {
|
||||
expect(dialogSource).toContain("showRoutineDeployDeferredHint");
|
||||
expect(dialogSource).toContain("canDeployRoutines");
|
||||
expect(dialogSource).toContain("isSchemaDiffPostgresLike");
|
||||
expect(dialogSource).toContain('t("diff.routineSyncDeferredHint")');
|
||||
expect(dialogSource).toContain('if (resultTab.value === "routines" && !canDeployRoutines.value) return false');
|
||||
expect(dialogSource).toContain("nextStepDeployRoutines");
|
||||
expect(dialogSource).toContain('t("diff.nextStepDeploy")');
|
||||
expect(dialogSource).toContain(':selectable="canDeployRoutines"');
|
||||
expect(configStepSource).toContain("api.listObjects");
|
||||
expect(configStepSource).not.toContain("api.listFunctions");
|
||||
});
|
||||
|
||||
it("uses the object tree for tables and a dedicated list plus text diff for routines", () => {
|
||||
expect(dialogSource).toContain('import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue"');
|
||||
expect(dialogSource).toContain('import SchemaDiffRoutineList from "@/components/diff/SchemaDiffRoutineList.vue"');
|
||||
expect(dialogSource).toContain('import SideBySideTextDiff, { type TextDiffSide } from "@/components/common/SideBySideTextDiff.vue"');
|
||||
expect(dialogSource).toContain('<SchemaDiffObjectTree :groups="tableDiffGroups"');
|
||||
expect(dialogSource).toContain("<SchemaDiffDdlPanel");
|
||||
expect(dialogSource).toContain("<SchemaDiffRoutineList");
|
||||
expect(dialogSource).toContain(':objects="routineDiffObjects"');
|
||||
expect(dialogSource).toContain("handleViewRoutineDiff");
|
||||
expect(dialogSource).toContain("<SideBySideTextDiff");
|
||||
expect(dialogSource).toContain('t("diff.selectRoutineToCompare")');
|
||||
expect(dialogSource).not.toContain(':groups="routineDiffGroups"');
|
||||
});
|
||||
|
||||
it("keeps focused and selected deployment SQL projections separate", () => {
|
||||
expect(dialogSource).toContain('const focusedDeploySql = ref("");');
|
||||
expect(dialogSource).toContain('const selectedDeploySql = ref("");');
|
||||
expect(dialogSource).toContain("const input = selectSchemaDiffInputForObject(result, diffObjects.value, objectId);");
|
||||
expect(dialogSource).toContain(':deploy-sql="focusedDeploySql"');
|
||||
expect(dialogSource).toContain(':deploy-sql-all="selectedDeploySql"');
|
||||
expect(dialogSource).not.toContain("deploySqlAll.value = result.syncSql");
|
||||
});
|
||||
|
||||
it("executes the selected SQL rather than the focused preview", () => {
|
||||
expect(dialogSource).toContain("sql: selectedDeploySql.value");
|
||||
expect(dialogSource).toContain("[selectedDeploySql.value]");
|
||||
expect(dialogSource).toContain("selectedObjectId.value !== objectId");
|
||||
expect(dialogSource).toContain('selectedDeploySql.value = "";');
|
||||
expect(dialogSource).toContain("const sql = selectedDeploySql.value.trim();");
|
||||
});
|
||||
});
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// #9118: the AI assistant exposes one "Auto" entry at the head of both action
|
||||
// menus. These assertions pin the parts of the wiring that a behavioral unit
|
||||
// test cannot reach (the component owns a large amount of shared state):
|
||||
//
|
||||
// - every explicit action stays in the menu, "auto" is only prepended;
|
||||
// - the selection starts concrete; Auto is preselected on new conversations
|
||||
// only through the Settings > AI "default auto routing" toggle;
|
||||
// - "auto" is resolved to a concrete action BEFORE the request is assembled, so
|
||||
// `taskContract.action` never carries it (the backend interpolates that value
|
||||
// into its system prompt and uses it for final-answer contract validation);
|
||||
// - the vector-DB branch (hidden picker, forced `generate`) is untouched;
|
||||
// - external explicit triggers (`triggerAction`) bypass the router.
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
function bodyOf(fnSignature: string): string {
|
||||
const start = source.indexOf(fnSignature);
|
||||
expect(start, `expected to find "${fnSignature}" in AiAssistant.vue`).toBeGreaterThanOrEqual(0);
|
||||
const braceStart = source.indexOf("{", start);
|
||||
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 "${fnSignature}"`);
|
||||
}
|
||||
|
||||
describe("Auto picker entry", () => {
|
||||
it("heads both action lists without removing any explicit action", () => {
|
||||
const askList = source.slice(source.indexOf("const askActionButtons: AiActionButton[] = ["), source.indexOf("const agentActionButtons: AiActionButton[] = ["));
|
||||
const agentList = source.slice(source.indexOf("const agentActionButtons: AiActionButton[] = ["), source.indexOf("const actionButtons = computed"));
|
||||
|
||||
for (const [label, list] of [
|
||||
["ask", askList],
|
||||
["agent", agentList],
|
||||
] as const) {
|
||||
const autoIdx = list.indexOf("autoActionButton,");
|
||||
expect(autoIdx, `${label}: auto entry`).toBeGreaterThanOrEqual(0);
|
||||
expect(autoIdx, `${label}: auto must be first`).toBeLessThan(list.indexOf('action: "general"'));
|
||||
// The pre-existing actions are all still present.
|
||||
expect(list, `${label}: general`).toContain('action: "general"');
|
||||
expect(list, `${label}: generate`).toContain('action: "generate"');
|
||||
}
|
||||
expect(askList).toContain('action: "sampleData"');
|
||||
expect(askList).toContain('action: "convert"');
|
||||
expect(agentList).toContain('action: "executeAndExplain"');
|
||||
expect(agentList).toContain('action: "exploreSchema"');
|
||||
expect(agentList).toContain('action: "query"');
|
||||
});
|
||||
|
||||
it("widens the picker type instead of the transport action union", () => {
|
||||
// The menu entry uses the UI-only `AiActionSelection`; `AiAction` (what the
|
||||
// request carries) stays a concrete action.
|
||||
const buttonInterface = bodyOf("interface AiActionButton {");
|
||||
expect(buttonInterface).toContain("action: AiActionSelection;");
|
||||
expect(buttonInterface).not.toContain("action: AiAction;");
|
||||
expect(source).toContain('import { classifyIntentByLlm, routeIntentByRules, type AiIntentRouteInput } from "@/lib/ai/aiIntentRouter";');
|
||||
expect(source).toContain("type AiActionSelection,");
|
||||
});
|
||||
|
||||
it("keeps resolveDefaultAction concrete; auto preselection flows only through the settings toggle", () => {
|
||||
expect(source).toContain('const activeAction = ref<AiActionSelection>("general");');
|
||||
const resolveDefault = bodyOf("function resolveDefaultAction(mode: AiAssistantMode): AiAction");
|
||||
expect(resolveDefault).not.toContain("auto");
|
||||
expect(resolveDefault).toContain("defaultActionForMode(mode)");
|
||||
|
||||
const resolveSelection = bodyOf("function resolveDefaultActionSelection(mode: AiAssistantMode): AiActionSelection");
|
||||
// Auto only when the Settings > AI toggle is on, and never for vector DBs:
|
||||
// their picker is hidden and the task contract must not invite querying.
|
||||
expect(resolveSelection).toContain("settings.defaultAutoRouting");
|
||||
expect(resolveSelection.indexOf('return "auto";')).toBeGreaterThanOrEqual(0);
|
||||
expect(resolveSelection.indexOf('return "auto";')).toBeLessThan(resolveSelection.indexOf("resolveDefaultAction(mode)"));
|
||||
expect(resolveSelection).toContain("isVectorDbType(boundConnection.value.db_type)");
|
||||
});
|
||||
|
||||
it("lands new conversations, mode switches and mount init on the settings-driven default", () => {
|
||||
expect(bodyOf("function startNewChat()")).toContain("resolveDefaultActionSelection(mode);");
|
||||
expect(bodyOf("watch(assistantMode, (mode) => {")).toContain("resolveDefaultActionSelection(mode);");
|
||||
expect(bodyOf('function switchModeActionTab(mode: "ask" | "agent") {')).toContain("resolveDefaultActionSelection(mode);");
|
||||
// Mount init applies the default exactly once (apply-once rule shared with
|
||||
// the default mode). The picker is panel-scoped: no restore path
|
||||
// (`selectConversation`) writes it, so the settings-driven default survives
|
||||
// restoring the last conversation.
|
||||
expect(source).toContain("activeAction.value = resolveDefaultActionSelection(settings.defaultAiMode);");
|
||||
});
|
||||
|
||||
it("hides the action list for vector databases and still forces generate there", () => {
|
||||
const listStart = source.indexOf("<!-- Action list -->");
|
||||
const pickerList = source.slice(listStart, source.indexOf("</PopoverContent>", listStart));
|
||||
expect(pickerList).toContain('v-for="button in actionButtons"');
|
||||
// The list (Auto included) only renders when the action picker is shown.
|
||||
const templateGuardIdx = source.lastIndexOf('<template v-if="showActionButtons">', listStart);
|
||||
expect(templateGuardIdx).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Keyed by the conversation's bound connection, not the visible tab: the
|
||||
// hidden action must match whichever database this chat actually targets.
|
||||
const vectorWatchStart = source.indexOf("() => boundConnection.value?.db_type,");
|
||||
const vectorWatch = source.slice(vectorWatchStart, source.indexOf("{ immediate: true },", vectorWatchStart));
|
||||
expect(vectorWatch).toContain('activeAction.value = "generate";');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auto routing at send time", () => {
|
||||
it("resolves the selection before the request reaches the backend", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const routingIdx = sendBody.indexOf("await resolveAutoAction(");
|
||||
const streamIdx = sendBody.indexOf("await runAgentStream(");
|
||||
const contractIdx = sendBody.indexOf("action: requestedAction,");
|
||||
expect(routingIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(streamIdx).toBeGreaterThan(routingIdx);
|
||||
expect(contractIdx).toBeGreaterThan(routingIdx);
|
||||
// Auto is only routed when it was actually selected; every explicit
|
||||
// selection is passed through untouched.
|
||||
expect(sendBody).toContain("requestedAction = requestedSelection;");
|
||||
// The send pipeline hands the router its frozen run target, so a background
|
||||
// send cannot route on the visible conversation's SQL or last error.
|
||||
expect(sendBody).toContain("requestedAction = await resolveAutoAction(text, requestedMode, runIsVisible(), tab);");
|
||||
// A confirmed-write turn replies with component copy, so the router must not
|
||||
// run on it: the mode default keeps the pre-Auto confirmation contract.
|
||||
expect(sendBody).toContain("const confirmationContinuation = allowWriteSqlForNextRun || resumingConfirmedWrite;");
|
||||
expect(sendBody).toContain("requestedAction = resolveDefaultAction(requestedMode);");
|
||||
// A stop/clear/switch that lands during the classifier wait must not start
|
||||
// a request nobody can reach.
|
||||
expect(sendBody.indexOf("if (!generationCanContinue()) {", routingIdx)).toBeGreaterThan(routingIdx);
|
||||
});
|
||||
|
||||
it("records the routing outcome on the user message and shows the recognizing indicator", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
expect(sendBody).toContain('userMessage.routedFrom = "auto";');
|
||||
expect(sendBody).toContain("userMessage.routedAction = requestedAction;");
|
||||
|
||||
const resolveBody = bodyOf("async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean, target: AiContextTarget = aiContextTarget.value): Promise<AiAction>");
|
||||
// Zero-latency rule layer first; the classifier (network) only on a miss.
|
||||
expect(resolveBody.indexOf("routeIntentByRules(input)")).toBeLessThan(resolveBody.indexOf("classifyIntentByLlm(input,"));
|
||||
expect(resolveBody).toContain("if (showProgress) routingInFlight.value = true;");
|
||||
expect(resolveBody).toContain("if (showProgress) routingInFlight.value = false;");
|
||||
expect(source).toContain("data-ai-auto-routing");
|
||||
expect(source).toContain('t("ai.routing.recognizing")');
|
||||
});
|
||||
|
||||
it("renders the Auto chip on the routed message and switches back to the explicit action", () => {
|
||||
expect(source).toContain("data-ai-auto-routed-chip");
|
||||
expect(source).toContain('t("ai.routing.chip", { action: actionLabelFor(routedActionOf(msg)) })');
|
||||
|
||||
const switchBody = bodyOf("function switchToRoutedAction(action: AiAction | null | undefined)");
|
||||
// Reuses the mode-switch suppression handshake so the watch cannot reset the
|
||||
// action right after the chip set it.
|
||||
expect(switchBody.indexOf("suppressModeActionReset = true;")).toBeLessThan(switchBody.indexOf('assistantMode.value = isValidActionForMode(action, "ask") ? "ask" : "agent";'));
|
||||
expect(switchBody.indexOf('assistantMode.value = isValidActionForMode(action, "ask") ? "ask" : "agent";')).toBeLessThan(switchBody.indexOf("activeAction.value = action;"));
|
||||
});
|
||||
|
||||
it("clears the recognizing indicator on the abandon path, not just the classifier's finally", () => {
|
||||
// Dual-path rule for per-request transient state (see
|
||||
// AiAssistant.clearAndCancel.spec.ts): the classifier's `finally` covers the
|
||||
// normal path, so clearMessages()/selectConversation()/onUnmounted must drop
|
||||
// the flag synchronously via resetPendingRequestState().
|
||||
expect(bodyOf("function resetPendingRequestState()")).toContain("routingInFlight.value = false;");
|
||||
expect(bodyOf("async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean, target: AiContextTarget = aiContextTarget.value): Promise<AiAction>")).toContain("if (showProgress) routingInFlight.value = false;");
|
||||
});
|
||||
|
||||
it("keeps external explicit triggers on the direct path", () => {
|
||||
const triggerBody = bodyOf("function triggerAction(action: AiAction, instruction?: string)");
|
||||
expect(triggerBody).toContain("activeAction.value = action;");
|
||||
expect(triggerBody).not.toContain("resolveAutoAction");
|
||||
expect(triggerBody).not.toContain("routeIntentByRules");
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Regression coverage for https://github.com/t8y2/dbx/issues/9904:
|
||||
//
|
||||
// Renaming the current conversation from the history list updates
|
||||
// `conversations` and the `renamedConversationTitles` claim map, but the
|
||||
// panel header rendered `chatTitle` purely from the first user message of
|
||||
// the panel-local `messages` snapshot, so the top-left title stayed stale
|
||||
// (reopening the conversation didn't pick the stored title up either).
|
||||
// AiAssistant.vue is a ~6000-line SFC the suite never mounts, so these
|
||||
// assertions pin the template/computed wiring as text, matching the house
|
||||
// source-assertion style.
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
function bodyOf(fnSignature: string): string {
|
||||
const start = source.indexOf(fnSignature);
|
||||
expect(start, `expected to find "${fnSignature}" in AiAssistant.vue`).toBeGreaterThanOrEqual(0);
|
||||
const braceStart = source.indexOf("{", start);
|
||||
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 "${fnSignature}"`);
|
||||
}
|
||||
|
||||
describe("AI assistant header title tracks the conversation title", () => {
|
||||
it("chatTitle prefers the renamed/stored title of the open conversation over the first-message excerpt", () => {
|
||||
// The claim map must stay reactive — it is the header's invalidation link
|
||||
// to a rename that commits while the save is still in flight.
|
||||
expect(source).toContain("const renamedConversationTitles = reactive(new Map<string, string>());");
|
||||
const body = bodyOf("const chatTitle = computed");
|
||||
// Both the rename-claim map and the conversation list must be reactive
|
||||
// dependencies so a commitRenameConversation() write re-renders the header.
|
||||
expect(body).toContain("renamedConversationTitles.get(conversationId.value)");
|
||||
expect(body).toContain("conversations.value.find(");
|
||||
expect(body).toContain("conversation.id === conversationId.value");
|
||||
expect(body).toContain("messages.value.find");
|
||||
// Same precedence as buildConversationSnapshot(): the rename claim wins,
|
||||
// the stored title comes next, and the first-message excerpt is only the
|
||||
// fallback for unsaved/new chats. (The toContain guards above keep these
|
||||
// index orderings from passing vacuously at -1.)
|
||||
expect(body.indexOf("renamedConversationTitles.get(conversationId.value)")).toBeLessThan(body.indexOf("activeConversation?.title"));
|
||||
expect(body.indexOf("renamedConversationTitles.get(conversationId.value)")).toBeLessThan(body.indexOf("messages.value.find"));
|
||||
expect(body).toContain("messageTitle(first).slice(0, 30)");
|
||||
expect(body).toContain('t("ai.newChat")');
|
||||
});
|
||||
|
||||
it("the header renders chatTitle and rename commits feed the sources chatTitle reads", () => {
|
||||
expect(source).toContain("{{ chatTitle }}");
|
||||
const commit = bodyOf("async function commitRenameConversation(conv: AiConversation)");
|
||||
// The claim must be set before the async save (title race guard) and the
|
||||
// list entry replaced afterwards — both writes are what make the header
|
||||
// reactive to the rename.
|
||||
expect(commit).toContain("renamedConversationTitles.set(conv.id, title);");
|
||||
expect(commit.indexOf("renamedConversationTitles.set(conv.id, title);")).toBeLessThan(commit.indexOf("await saveAiConversation(updated)"));
|
||||
expect(commit).toContain("conversations.value[i] = updated;");
|
||||
// A failed save retracts the claim so the header, the history row and the
|
||||
// persisted record all keep the old title.
|
||||
expect(commit).toContain("renamedConversationTitles.delete(conv.id);");
|
||||
});
|
||||
|
||||
it("pending-input recovery keeps a renamed title instead of re-deriving it from the first message", () => {
|
||||
// A recovered queued run used to persist `messageTitle(first)` outright,
|
||||
// overwriting a renamed title on disk (and, via syncPersistedConversation,
|
||||
// in the list the header now reads).
|
||||
const recovery = bodyOf("async function persistPendingInputRecovery(");
|
||||
expect(recovery).toContain("renamedConversationTitles.get(conversation.id)");
|
||||
expect(recovery.indexOf("renamedConversationTitles.get(conversation.id)")).toBeLessThan(recovery.indexOf("messageTitle(first)"));
|
||||
});
|
||||
});
|
||||
-548
@@ -1,548 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Regression coverage for https://github.com/t8y2/dbx/issues/5941 and the follow-up
|
||||
// race identified on https://github.com/t8y2/dbx/pull/6332:
|
||||
//
|
||||
// When an in-flight AI request is stuck (e.g. a hung MCP tool call / slow provider
|
||||
// response), clicking the clear-chat trash icon, "New Chat", deleting the active
|
||||
// conversation, or switching to a different saved conversation must (a) cancel the
|
||||
// in-flight request so `isGenerating` doesn't stay stranded true forever, and (b)
|
||||
// isolate that in-flight request's async event callbacks / catch / finally from
|
||||
// whatever conversation is active by the time they run — even if the backend cancel
|
||||
// RPC itself is a no-op because the request hadn't registered a session id yet.
|
||||
//
|
||||
// (b) is what `AiGenerationGuard` (lib/ai/aiGenerationGuard.ts) exists for. Its own
|
||||
// spec and aiConversationLifecycle.spec.ts exercise the async ordering directly;
|
||||
// this file pins that AiAssistant.vue wires those tested lifecycle helpers into the
|
||||
// component paths that own the shared state.
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
function bodyOf(fnSignature: string): string {
|
||||
const start = source.indexOf(fnSignature);
|
||||
expect(start, `expected to find "${fnSignature}" in AiAssistant.vue`).toBeGreaterThanOrEqual(0);
|
||||
const braceStart = source.indexOf("{", start);
|
||||
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 "${fnSignature}"`);
|
||||
}
|
||||
|
||||
describe("AI assistant uses platform-specific conversation lifecycle", () => {
|
||||
it("abandonInFlightRequest() invalidates the generation guard before/regardless of the backend RPC", () => {
|
||||
const body = bodyOf("function abandonInFlightRequest(alreadyCancelledSessionId?: string)");
|
||||
expect(body).toContain("aiGenerationGuard.invalidate();");
|
||||
expect(body).toContain("isGenerating.value = false;");
|
||||
expect(body).toContain('currentSessionId.value = "";');
|
||||
// Invalidation (and resetting isGenerating/currentSessionId/delta buffers) must
|
||||
// happen unconditionally and before the best-effort backend RPC, so a send()
|
||||
// that hasn't registered a session id yet is still cut off from shared state.
|
||||
expect(body.indexOf("aiGenerationGuard.invalidate();")).toBeLessThan(body.indexOf("if (sessionId && sessionId !== alreadyCancelledSessionId) {"));
|
||||
});
|
||||
|
||||
it("clearMessages() keeps Web cancellation while Desktop detaches the view", () => {
|
||||
const body = bodyOf("function clearMessages()");
|
||||
expect(body).toContain("if (isGenerating.value && !backgroundAiRunsEnabled) abandonInFlightRequest();");
|
||||
expect(body).toContain("if (isGenerating.value && backgroundAiRunsEnabled) void persistConversation();");
|
||||
// The abandon guard must run before the history is actually wiped, not after.
|
||||
expect(body.indexOf("!backgroundAiRunsEnabled) abandonInFlightRequest();")).toBeLessThan(body.indexOf("messages.value = []"));
|
||||
});
|
||||
|
||||
it("selectConversation() cancels on Web and reattaches to a Desktop run", () => {
|
||||
const body = bodyOf("function selectConversation(conv: AiConversation)");
|
||||
expect(body).toContain("if (isGenerating.value && !backgroundAiRunsEnabled) abandonInFlightRequest();");
|
||||
expect(body).toContain("desktopAiRun<ChatMessage>(conv.id)");
|
||||
expect(body.indexOf("!backgroundAiRunsEnabled) abandonInFlightRequest();")).toBeLessThan(body.indexOf("conversationId.value = conv.id;"));
|
||||
});
|
||||
|
||||
it("cancelStream() delegates live state readers to the tested stop lifecycle", () => {
|
||||
const body = bodyOf("async function cancelStream()");
|
||||
expect(body).toContain("await stopAiGenerationWithFallback({");
|
||||
expect(body).toContain("currentGeneration: () => aiGenerationGuard.peek()");
|
||||
expect(body).toContain("currentAssistantMessageIndex: () => currentAssistantMessageIndex");
|
||||
expect(body).toContain("abandon: (sessionId) => abandonInFlightRequest(sessionId)");
|
||||
expect(body).toContain("persistConversation,");
|
||||
});
|
||||
|
||||
it("startNewChat() clears through the guarded path and performDeleteConversation() uses the tested delete lifecycle", () => {
|
||||
expect(bodyOf("function startNewChat()")).toContain("clearMessages();");
|
||||
const deleteBody = bodyOf("async function performDeleteConversation(id: string)");
|
||||
expect(deleteBody).toContain("await deleteConversationWithCancellation({");
|
||||
expect(deleteBody).toContain("abandon: () => abandonInFlightRequest()");
|
||||
expect(deleteBody).toContain("deletePersisted: () => deleteAiConversation(id).catch(() => {})");
|
||||
expect(deleteBody).toContain("if (conversationId.value === id) clearMessages();");
|
||||
});
|
||||
|
||||
it("deleteConversation() asks for confirmation only when the conversation owns an active task", () => {
|
||||
// Parent PRD §4 line 70: deleting a conversation with a running/queued/
|
||||
// awaiting task must first ask; a conversation without an active task still
|
||||
// deletes directly.
|
||||
const deleteBody = bodyOf("async function deleteConversation(id: string)");
|
||||
expect(deleteBody).toContain("if (backgroundAiRunsEnabled && conversationHasActiveTask(id)) {");
|
||||
expect(deleteBody).toContain("deleteConfirmConversationId.value = id;");
|
||||
expect(deleteBody).toContain("await performDeleteConversation(id);");
|
||||
const taskBody = bodyOf("function conversationHasActiveTask(id: string)");
|
||||
expect(taskBody).toContain('status === "awaiting_write_confirmation"');
|
||||
expect(taskBody).toContain('status === "pending_recoverable"');
|
||||
});
|
||||
|
||||
it("performDeleteConversation() marks desktop runs discardOnFinish so no async write can resurrect them", () => {
|
||||
// Reviewed finding (delete-resurrection, HIGH): a queued/running background
|
||||
// run's snapshot persists fired AFTER deleteAiConversation committed, so the
|
||||
// INSERT OR REPLACE recreated the just-deleted conversation. The delete path
|
||||
// must flag the run so every persist site skips it.
|
||||
const body = bodyOf("async function performDeleteConversation(id: string)");
|
||||
expect(body).toContain("run.discardOnFinish = true;");
|
||||
expect(body).toContain('if (run.status === "queued") cancelQueuedDesktopAiRun(run);');
|
||||
// The run is never finished here: the delete removes it from the registry
|
||||
// immediately and the bounded background cleanup owns finalization, so a
|
||||
// finished-but-still-slot-holding run can't wedge the queue.
|
||||
expect(body).not.toContain('finishDesktopAiRun(run, "cancelled");');
|
||||
expect(body).toContain("releaseDeletedRunSlot(run);");
|
||||
// The confirm path must cancel runs in ALL active statuses, including a
|
||||
// pending write confirmation and a recovered draft.
|
||||
expect(body).toContain('run.status === "awaiting_write_confirmation"');
|
||||
expect(body).toContain('run.status === "pending_recoverable"');
|
||||
});
|
||||
|
||||
it("performDeleteConversation() launches bounded slot release so a deleted-but-hung run cannot wedge the queue", () => {
|
||||
// Reviewed finding (slot leak, HIGH): the delete path removed the run from
|
||||
// the registry immediately but never released its admittedRunIds slot when
|
||||
// the backend stream was hung — after DESKTOP_AI_CONCURRENCY_LIMIT such
|
||||
// deletes the global queue wedges permanently. The bounded release waits
|
||||
// for the pipeline's settled signal (or the force-abandon deadline) and
|
||||
// frees the slot; it also must not block the delete on the cancel RPC.
|
||||
const releaseBody = bodyOf("function releaseDeletedRunSlot(run: DesktopAiRunRuntime<ChatMessage>)");
|
||||
expect(releaseBody).toContain("if (!run.settled) return;");
|
||||
expect(releaseBody).toContain("waitForDesktopRunSettled(run)");
|
||||
expect(releaseBody).toContain("releaseDesktopAiRunSlot(run.runId)");
|
||||
expect(releaseBody).toContain("requestDesktopRunCancellation(run, deadlineAt, false)");
|
||||
const deleteBody = bodyOf("async function performDeleteConversation(id: string)");
|
||||
expect(deleteBody).not.toContain("await aiCancelStream(");
|
||||
});
|
||||
|
||||
it("send() guards every snapshot persist behind discardOnFinish so a deleted conversation can't be resurrected", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
// All three send()-internal persist sites (queued persist, unslotted
|
||||
// early-return, and the pre-stream snapshot) must be guarded — previously
|
||||
// some persisted unconditionally.
|
||||
const guardedSites = sendBody.match(/if \(!detachedRun\.discardOnFinish\) void runSnapshotScheduler\.save\(detachedRun\);/g) ?? [];
|
||||
expect(guardedSites.length).toBe(3);
|
||||
// The finally must skip the run snapshot when the run was deleted, and the
|
||||
// Web-only conversation fallback must not fire for a deleted Desktop run.
|
||||
const finallyIdx = sendBody.indexOf("} finally {");
|
||||
const finallyBody = sendBody.slice(finallyIdx);
|
||||
expect(finallyBody).toContain("if (!detachedRun.discardOnFinish) {");
|
||||
expect(finallyBody).not.toContain("else void persistConversationSnapshot(");
|
||||
});
|
||||
|
||||
it("send() tags a queued run's FIFO category so restart recovery can classify it", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const queueIdx = sendBody.indexOf('if (detachedRun.status === "queued")');
|
||||
expect(queueIdx).toBeGreaterThanOrEqual(0);
|
||||
const queuedBlock = sendBody.slice(queueIdx);
|
||||
expect(queuedBlock).toContain('fifoCategory: resumingConfirmedWrite ? "write_confirmation_resume" : "normal_send"');
|
||||
expect(queuedBlock).toContain("pendingInput: resumingConfirmedWrite ? detachedRun.pendingInput : text");
|
||||
});
|
||||
|
||||
it("send() discards a recovered pending-input run before starting a fresh one", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const registerIdx = sendBody.indexOf("const resumableRun = desktopAiRun<ChatMessage>(runConversationId);");
|
||||
expect(registerIdx).toBeGreaterThanOrEqual(0);
|
||||
const registerBlock = sendBody.slice(registerIdx);
|
||||
expect(registerBlock).toContain('resumableRun?.status === "pending_recoverable"');
|
||||
expect(registerBlock).toContain("resumableRun.discardOnFinish = true;");
|
||||
expect(registerBlock).toContain("removeDesktopAiRun(resumableRun.conversationId);");
|
||||
});
|
||||
|
||||
it("send() claims a generation id right after setting isGenerating and re-checks it after the first await", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const claimIdx = sendBody.indexOf("const myGeneration = aiGenerationGuard.begin();");
|
||||
expect(claimIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(sendBody.indexOf("isGenerating.value = true;")).toBeLessThan(claimIdx);
|
||||
// Must re-validate after the first await point (ensureLoaded()) before doing
|
||||
// anything that touches messages/mentions belonging to whatever conversation
|
||||
// is current by the time it resolves.
|
||||
const ensureLoadedAwaitIdx = sendBody.indexOf("await promptTemplateStore.ensureLoaded()");
|
||||
const recheckIdx = sendBody.indexOf("if (!generationCanContinue())", ensureLoadedAwaitIdx);
|
||||
expect(ensureLoadedAwaitIdx).toBeGreaterThan(claimIdx);
|
||||
expect(recheckIdx).toBeGreaterThan(ensureLoadedAwaitIdx);
|
||||
});
|
||||
|
||||
it("send() clears the pending write-SQL grant when superseded before it's consumed", () => {
|
||||
// Reviewed on PR #6332: at this point in send(), the write-SQL grant
|
||||
// (allowWriteSqlForNextRun/confirmedWriteSqlText/...) hasn't been read/reset
|
||||
// yet — that happens later, right before runAgentStream(). A bare `return`
|
||||
// here would leave a previously-confirmed write grant sitting in the
|
||||
// module-scope vars, live to be replayed against whatever unrelated send()
|
||||
// the next conversation issues.
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const ensureLoadedAwaitIdx = sendBody.indexOf("await promptTemplateStore.ensureLoaded()");
|
||||
const grantConsumedIdx = sendBody.indexOf("const allowWriteSql = requestedMode", ensureLoadedAwaitIdx);
|
||||
const recheckIdx = sendBody.indexOf("if (!generationCanContinue()) {", ensureLoadedAwaitIdx);
|
||||
expect(recheckIdx).toBeGreaterThan(ensureLoadedAwaitIdx);
|
||||
expect(recheckIdx).toBeLessThan(grantConsumedIdx);
|
||||
const recheckEnd = sendBody.indexOf("}", recheckIdx);
|
||||
const recheckBlock = sendBody.slice(recheckIdx, recheckEnd);
|
||||
expect(recheckBlock).toContain("clearPendingWriteGrant();");
|
||||
expect(recheckBlock).toContain("return;");
|
||||
});
|
||||
|
||||
it("send()'s agent-event callback bails immediately if superseded", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const callbackStart = sendBody.indexOf("(event: AgentEvent) => {");
|
||||
expect(callbackStart).toBeGreaterThanOrEqual(0);
|
||||
const guardIdx = sendBody.indexOf("if (!generationCanContinue()) return;", callbackStart);
|
||||
const pushIdx = sendBody.indexOf("agentEvents.push(event);", callbackStart);
|
||||
expect(guardIdx).toBeGreaterThan(callbackStart);
|
||||
expect(guardIdx).toBeLessThan(pushIdx);
|
||||
});
|
||||
|
||||
it("send()'s catch block guards the assistant message lookup behind the generation check", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const start = sendBody.indexOf("} catch (e: unknown) {");
|
||||
expect(start, 'expected to find a "} catch (e: unknown) {" block inside send()').toBeGreaterThanOrEqual(0);
|
||||
const end = sendBody.indexOf("} finally {", start);
|
||||
const catchBody = sendBody.slice(start, end);
|
||||
// Must not index messages.value[assistantIdx] unguarded — if clearMessages()/
|
||||
// selectConversation() already replaced the array (or invalidated the
|
||||
// generation) while this request was still in flight, an unguarded write would
|
||||
// either throw (silently eating the real error) or corrupt a different
|
||||
// conversation's transcript.
|
||||
expect(catchBody).not.toContain("messages.value[assistantIdx].content =");
|
||||
expect(catchBody).toContain("generationCanContinue()");
|
||||
expect(catchBody).toContain("const msg = runMessages[assistantIdx];");
|
||||
expect(catchBody).toContain("if (msg) {");
|
||||
expect(catchBody).toContain("msg.content =");
|
||||
// The failure flag rides along with the prefix text (#6467 PR3): the export
|
||||
// derives its failure marker from this state, not from the localized text.
|
||||
expect(catchBody).toContain("msg.failed = true;");
|
||||
});
|
||||
|
||||
it("send()'s finally block only mutates shared state (isGenerating, currentSessionId) when still current", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const start = sendBody.indexOf("} finally {");
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
const finallyBody = sendBody.slice(start);
|
||||
const guardIdx = finallyBody.indexOf("if (detachedRun || aiGenerationGuard.isCurrent(myGeneration)) {");
|
||||
expect(guardIdx).toBeGreaterThanOrEqual(0);
|
||||
const isGeneratingIdx = finallyBody.indexOf("isGenerating.value = false;");
|
||||
const sessionResetIdx = finallyBody.indexOf('currentSessionId.value = "";');
|
||||
expect(isGeneratingIdx).toBeGreaterThan(guardIdx);
|
||||
expect(sessionResetIdx).toBeGreaterThan(guardIdx);
|
||||
// Issue #6743 feature 1 dual-path reset: the normal-completion path must stop
|
||||
// the 1s status timer and clear the generation-status ref inside the guarded
|
||||
// finally (the abandon path clears it via resetPendingRequestState()).
|
||||
expect(finallyBody.indexOf("stopStatusTimer();")).toBeGreaterThan(guardIdx);
|
||||
expect(finallyBody.indexOf("generationStatus.value = createGenerationStatus(Date.now());")).toBeGreaterThan(guardIdx);
|
||||
});
|
||||
|
||||
it("the generation-status line exposes a screen-reader live region (role=status) that excludes ticking numerals", () => {
|
||||
// Issue #6743 feature 1 a11y: async execution-state updates must be
|
||||
// announced. The status block (data-ai-generation-status) must contain a
|
||||
// role="status" live region fed by `statusLiveAnnouncement` — which, unlike
|
||||
// the visible `statusText`, omits the per-second elapsed/idle numerals so a
|
||||
// screen reader hears discrete state changes, not a timer ticking every 1s.
|
||||
const statusBlockStart = source.indexOf("data-ai-generation-status");
|
||||
expect(statusBlockStart).toBeGreaterThanOrEqual(0);
|
||||
const block = source.slice(statusBlockStart);
|
||||
expect(block).toContain('role="status"');
|
||||
expect(block).toContain('aria-live="polite"');
|
||||
expect(block).toContain('aria-atomic="true"');
|
||||
expect(block).toContain("statusLiveAnnouncement");
|
||||
expect(block).toContain('class="sr-only"');
|
||||
});
|
||||
|
||||
it("the generation-status line hides once the generation is finished (agent_end) even before isGenerating clears", () => {
|
||||
// Issue #6743 fix: agent_end/error arrive via the event callback before
|
||||
// runAgentStream()'s promise resolves (CLI teardown / SSE close can take
|
||||
// seconds), so the status line must ALSO be gated on phase !== 'finished' —
|
||||
// otherwise it lingers below the completed reply showing a reset "0s".
|
||||
const statusLineIdx = source.indexOf("data-ai-generation-status");
|
||||
expect(statusLineIdx).toBeGreaterThanOrEqual(0);
|
||||
const lineStart = source.lastIndexOf("\n", statusLineIdx) + 1;
|
||||
const lineEnd = source.indexOf("\n", statusLineIdx);
|
||||
const openingTag = source.slice(lineStart, lineEnd);
|
||||
expect(openingTag).toContain("generationStatus.phase !== 'finished'");
|
||||
expect(openingTag).toContain("data-ai-generation-status");
|
||||
});
|
||||
|
||||
it("the >60s long-running hint is hidden once the generation is finished", () => {
|
||||
// Fix keeps startedAt on the finished phase, so the hint must not reappear
|
||||
// under a completed reply during the isGenerating-still-true gap.
|
||||
const idx = source.indexOf("const statusLongRunningHintVisible");
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
const line = source.slice(idx, source.indexOf("\n", idx));
|
||||
expect(line).toContain('"finished"');
|
||||
});
|
||||
|
||||
// The three gaps below were called out on review of PR #6332: the generation guard
|
||||
// stopped *writes* from a superseded generation, but didn't stop the generation from
|
||||
// starting a stream, leaking pending-compaction state, or from surviving unmount.
|
||||
it("send() rechecks the generation after EACH context-preparation await, before ever starting the stream", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const sqlFilesIdx = sendBody.indexOf("await loadReferencedSqlFiles(");
|
||||
const firstRecheckIdx = sendBody.indexOf("if (!generationCanContinue()) return;", sqlFilesIdx);
|
||||
const contextIdx = sendBody.indexOf("const context = await buildAiContext(");
|
||||
const secondRecheckIdx = sendBody.indexOf("if (!generationCanContinue()) return;", contextIdx);
|
||||
const runIdx = sendBody.indexOf("await runAgentStream(", contextIdx);
|
||||
// A clear/switch/unmount firing during EITHER await invalidates the generation
|
||||
// but doesn't touch the backend (no session registered yet) — without a
|
||||
// recheck immediately after each one, send() would resume into further wasted
|
||||
// work (buildAiContext() can do real backend/schema work) and eventually call
|
||||
// runAgentStream() anyway, starting a request nobody can reach anymore.
|
||||
expect(sqlFilesIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(firstRecheckIdx).toBeGreaterThan(sqlFilesIdx);
|
||||
expect(firstRecheckIdx).toBeLessThan(contextIdx);
|
||||
expect(secondRecheckIdx).toBeGreaterThan(contextIdx);
|
||||
expect(runIdx).toBeGreaterThan(secondRecheckIdx);
|
||||
});
|
||||
|
||||
it("send() tracks the current assistant message index alongside currentSessionId, for cancelStream() to finalize", () => {
|
||||
// cancelStream()'s forced-abandon path needs to know which message in
|
||||
// messages.value belongs to this generation so it can finalize it (see the
|
||||
// "finalizes the stuck placeholder message" test above). Must be set at the
|
||||
// same point currentSessionId is, and cleared alongside it too.
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const pushIdx = sendBody.indexOf('runMessages.push({ role: "assistant"');
|
||||
const assistantIdxIdx = sendBody.indexOf("const assistantIdx = runMessages.length - 1;", pushIdx);
|
||||
const trackIdx = sendBody.indexOf("currentAssistantMessageIndex = assistantIdx;", assistantIdxIdx);
|
||||
const sessionSetIdx = sendBody.indexOf("currentSessionId.value = sessionId;", trackIdx);
|
||||
expect(pushIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(assistantIdxIdx).toBeGreaterThan(pushIdx);
|
||||
expect(trackIdx).toBeGreaterThan(assistantIdxIdx);
|
||||
expect(sessionSetIdx).toBeGreaterThan(trackIdx);
|
||||
|
||||
const start = sendBody.indexOf("} finally {");
|
||||
const finallyBody = sendBody.slice(start);
|
||||
const sessionResetIdx = finallyBody.indexOf('currentSessionId.value = "";');
|
||||
const indexResetIdx = finallyBody.indexOf("currentAssistantMessageIndex = -1;");
|
||||
expect(sessionResetIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(indexResetIdx).toBeGreaterThan(sessionResetIdx);
|
||||
});
|
||||
|
||||
it("abandonInFlightRequest() skips the cancel RPC for a session the caller already cancelled", () => {
|
||||
// Reviewed on PR #6332 (efficiency): cancelStream() awaits aiCancelStream()
|
||||
// itself before deciding to force-abandon, so re-firing it unconditionally
|
||||
// here would double-RPC the same session. Must still fire for a session
|
||||
// registered AFTER the caller's own RPC attempt (i.e. not simply skipped
|
||||
// whenever a caller happened to pass an argument).
|
||||
const body = bodyOf("function abandonInFlightRequest(alreadyCancelledSessionId?: string)");
|
||||
expect(body).toContain("if (sessionId && sessionId !== alreadyCancelledSessionId) {");
|
||||
expect(body).not.toContain("if (sessionId) {\n aiCancelStream(sessionId).catch(() => {});\n }");
|
||||
});
|
||||
|
||||
it("resetPendingRequestState() resets all per-request transient state, and abandonInFlightRequest() uses it", () => {
|
||||
// Reviewed on PR #6332: this reset logic used to be duplicated inline across
|
||||
// send()'s finally, abandonInFlightRequest(), and flushAssistantDeltas() —
|
||||
// duplication that already let the pendingCompaction reset get missed once.
|
||||
// Consolidating it into one function means a newly-added piece of per-request
|
||||
// state only needs to be added here to be covered by the abandon path.
|
||||
const resetBody = bodyOf("function resetPendingRequestState()");
|
||||
expect(resetBody).toContain("cancelAnimationFrame(assistantDeltaFrame);");
|
||||
expect(resetBody).toContain("assistantDeltaFrame = null;");
|
||||
expect(resetBody).toContain('pendingAssistantDelta = "";');
|
||||
expect(resetBody).toContain('pendingAssistantReasoning = "";');
|
||||
expect(resetBody).toContain("pendingAssistantIndex = -1;");
|
||||
// A context_compacted event on the abandoned generation may have already set
|
||||
// pendingCompaction before invalidation took effect. It's request-owned, not
|
||||
// conversation-owned, so it must be reset here — otherwise the next send() (in
|
||||
// a brand-new, just-cleared conversation) would splice the OLD conversation's
|
||||
// compaction summary into the NEW conversation's transcript in its finally
|
||||
// block.
|
||||
expect(resetBody).toContain("pendingCompaction.value = null;");
|
||||
// Issue #6743 feature 1: the live generation-status line is per-request
|
||||
// transient state too — the 1s status timer and the status ref must be reset
|
||||
// here, otherwise switching conversations leaks a stale status line (and a
|
||||
// running interval) into the next generation.
|
||||
expect(resetBody).toContain("stopStatusTimer();");
|
||||
expect(resetBody).toContain("generationStatus.value = createGenerationStatus(Date.now());");
|
||||
|
||||
const abandonBody = bodyOf("function abandonInFlightRequest(alreadyCancelledSessionId?: string)");
|
||||
expect(abandonBody).toContain("resetPendingRequestState();");
|
||||
});
|
||||
|
||||
it("onUnmounted() invalidates the generation instead of only firing the best-effort cancel RPC", () => {
|
||||
const body = bodyOf("onUnmounted(() => {");
|
||||
// Plain cancelStream() leaves the generation current: a request still mid-await
|
||||
// when the component unmounts would resume, call runAgentStream(), and its event
|
||||
// callback/catch/finally would keep writing into refs this now-unmounted
|
||||
// instance's closures still hold. Must go through the same invalidating path as
|
||||
// clearMessages()/selectConversation().
|
||||
expect(body).toContain("if (isGenerating.value && !backgroundAiRunsEnabled) abandonInFlightRequest();");
|
||||
expect(body).not.toContain("cancelStream();");
|
||||
});
|
||||
|
||||
it("queues simultaneous background auto-sends instead of overwriting a single pending target", () => {
|
||||
// Several detached runs can settle in the same turn. A single mutable
|
||||
// `pendingAutoSend` slot lost whichever queued input was scheduled first.
|
||||
expect(source).toContain("const pendingAutoSends: PendingAutoSend[] = [];");
|
||||
const sendBody = bodyOf("async function send()");
|
||||
expect(sendBody).toContain("const auto = pendingAutoSends.shift() ?? null;");
|
||||
const scheduleBody = bodyOf("function scheduleAutoSend(");
|
||||
expect(scheduleBody).toContain("pendingAutoSends.push(");
|
||||
});
|
||||
|
||||
it("makes successfully persisted background conversations navigable without reopening the list", () => {
|
||||
const snapshotBody = bodyOf("async function persistDesktopRunSnapshot(");
|
||||
expect(snapshotBody).toContain(".then(() => syncPersistedConversation(conversation))");
|
||||
const syncBody = bodyOf("function syncPersistedConversation(conversation: AiConversation)");
|
||||
expect(syncBody).toContain("conversations.value.unshift(conversation);");
|
||||
expect(syncBody).toContain("conversations.value.sort(");
|
||||
});
|
||||
|
||||
it("reorders recovered conversations before restoring the most recent one", () => {
|
||||
const recoveryBody = bodyOf("async function persistPendingInputRecovery(");
|
||||
expect(recoveryBody).toContain(".then(() => syncPersistedConversation(snapshot))");
|
||||
const mountedBody = bodyOf("onMounted(async () => {");
|
||||
expect(mountedBody.indexOf("initialConversationStateLoaded = true;")).toBeLessThan(mountedBody.indexOf("restoreInitialConversation();"));
|
||||
});
|
||||
|
||||
it("does not replace an in-memory desktop run when the AI panel remounts", () => {
|
||||
const mountedBody = bodyOf("onMounted(async () => {");
|
||||
expect(mountedBody).toContain("const liveRun = desktopAiRun<ChatMessage>(persistedRun.conversationId);");
|
||||
expect(mountedBody).toContain("if (liveRun) {");
|
||||
expect(mountedBody).toContain("continue;");
|
||||
});
|
||||
|
||||
it("agent step cards render a running-tool tail and a computed duration tail", () => {
|
||||
// Issue #6743 (feature-1 gap): per-tool execution time in the agent step cards —
|
||||
// mockup shows a spinner + "执行中…" tail on running steps and `0.8s`/`1.2s` on
|
||||
// completed steps. The step-row template must special-case running tool steps
|
||||
// (spinner icon + executing tail) and completed tool steps (tabular duration).
|
||||
const stepsStart = source.indexOf('v-for="step in msg.agentSteps"');
|
||||
expect(stepsStart, "expected to find the agent-steps v-for in AiAssistant.vue").toBeGreaterThanOrEqual(0);
|
||||
const stepsBlock = source.slice(stepsStart);
|
||||
// Running tool step: spinner leading icon + right-aligned "executing…" tail.
|
||||
expect(stepsBlock).toContain("step.tone === 'active' && step.toolName");
|
||||
expect(stepsBlock).toContain('t("ai.agentSteps.executing")');
|
||||
// Completed tool step: right-aligned tabular duration tail.
|
||||
expect(stepsBlock).toContain("formatToolDurationMs(step.durationMs)");
|
||||
});
|
||||
|
||||
it("the status-line idle branch swaps Clock for Hourglass (mockup alignment)", () => {
|
||||
// Mockup: idle state shows a non-spinning hourglass (spinner animation stops);
|
||||
// only the >60s hint below keeps the Clock. The swap must live in the status
|
||||
// line's spinner/clock slot, not touch the hint.
|
||||
const statusLineIdx = source.indexOf("data-ai-generation-status");
|
||||
expect(statusLineIdx).toBeGreaterThanOrEqual(0);
|
||||
const statusBlock = source.slice(statusLineIdx);
|
||||
expect(statusBlock).toContain("<Hourglass v-else");
|
||||
});
|
||||
|
||||
// --- Review findings on background AI session tasks (see the two findings
|
||||
// evaluated in this session) ---
|
||||
|
||||
it("persists detached streaming snapshots incrementally from the delta flush path", () => {
|
||||
// Finding 1: detached deltas were flushed only into memory (plus a scroll
|
||||
// callback), so a crash/quit mid-response lost everything after the
|
||||
// pre-stream snapshot. The flush path must now schedule throttled,
|
||||
// serialized snapshot persistence.
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const bufferIdx = sendBody.indexOf("createDetachedAssistantDeltaBuffer(runMessages, () => {");
|
||||
expect(bufferIdx).toBeGreaterThanOrEqual(0);
|
||||
const flushBlock = sendBody.slice(bufferIdx, sendBody.indexOf(": undefined", bufferIdx));
|
||||
expect(flushBlock).toContain("runSnapshotScheduler.schedule(detachedRun);");
|
||||
// The pre-stream snapshot and the finally go through the same serialized
|
||||
// scheduler so a slow write can never reorder around them.
|
||||
expect(sendBody).toContain("void runSnapshotScheduler.save(detachedRun)");
|
||||
// The scheduler itself lives at module scope (the flush callback and the
|
||||
// stop path both use it), configured with the streaming interval.
|
||||
expect(source).toContain("const runSnapshotScheduler = createDesktopAiRunSnapshotScheduler<ChatMessage>({");
|
||||
expect(source).toContain("intervalMs: RUN_SNAPSHOT_PERSIST_INTERVAL_MS");
|
||||
});
|
||||
|
||||
it("send() resolves the run's settled signal on every exit path so a stop can wait for the real terminal event", () => {
|
||||
// Finding 2: the stop path must not finalize the run itself; it waits for
|
||||
// the pipeline's terminal event via the run's `settled` promise. Every
|
||||
// exit path of send() must resolve it (finally + three pre-stream early
|
||||
// returns), or a stop fired there would sit out the full timeout.
|
||||
const sendBody = bodyOf("async function send()");
|
||||
expect(sendBody).toContain("detachedRun.settled = new Promise<void>((resolve) => {");
|
||||
const finallyIdx = sendBody.indexOf("} finally {");
|
||||
const finallyBody = sendBody.slice(finallyIdx);
|
||||
expect(finallyBody).toContain("resolveDetachedRunSettled();");
|
||||
const settledCalls = sendBody.match(/resolveDetachedRunSettled\(\);/g) ?? [];
|
||||
expect(settledCalls.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("the finally skips finalization for a run that no longer owns its registry slot", () => {
|
||||
// Finding 2: after a stop-side force-abandon (or a delete/replacement send)
|
||||
// retires the run, the old pipeline's finally must not re-run the finish
|
||||
// chain - updateDesktopAiRun() re-inserts unconditionally, which would
|
||||
// resurrect the retired run over whatever now owns the conversation.
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const finallyIdx = sendBody.indexOf("} finally {");
|
||||
const finallyBody = sendBody.slice(finallyIdx);
|
||||
expect(finallyBody).toContain("const runStillOwned = detachedRun ? desktopAiRun(detachedRun.conversationId) === detachedRun : false;");
|
||||
expect(finallyBody).toContain("if (detachedRun && runStillOwned) {");
|
||||
expect(finallyBody).toContain("runSnapshotScheduler.cancel(detachedRun.runId);");
|
||||
expect(finallyBody).toContain("void runSnapshotScheduler.save(detachedRun).finally(");
|
||||
expect(finallyBody).toContain("} else if (!detachedRun) {");
|
||||
});
|
||||
|
||||
it("stopDesktopAiRun keeps a background run active until the pipeline settles, force-abandoning after the timeout", () => {
|
||||
// Finding 2: the old stop path finished+retired immediately, so a hung or
|
||||
// cancellation-pending stream went invisible while still occupying its slot.
|
||||
// The shared helper must reflect the request but defer finalization to the
|
||||
// pipeline's finally or the bounded force-abandon.
|
||||
const body = bodyOf("async function stopDesktopAiRun(");
|
||||
expect(body).toContain("run.cancelRequested = true;");
|
||||
expect(body).toContain("waitForDesktopRunSettled(run)");
|
||||
expect(body).toContain("requestDesktopRunCancellation(run, deadlineAt)");
|
||||
expect(body).toContain("await forceAbandonDesktopAiRun(run);");
|
||||
// Only the never-admitted queued branch finalizes immediately.
|
||||
expect(body).toContain('if (run.status === "queued")');
|
||||
expect(body).toContain("cancelQueuedDesktopAiRun(run);");
|
||||
});
|
||||
|
||||
it("the stop path consumes the aiCancelStream acknowledgement and retries while unacknowledged", () => {
|
||||
// Finding 2: aiCancelStream() returns a boolean (true only when the session
|
||||
// id is registered with the backend) that was discarded. The retry loop
|
||||
// lands the cancellation once the session registers during context
|
||||
// preparation.
|
||||
const body = bodyOf("async function requestDesktopRunCancellation(");
|
||||
expect(body).toContain("const acknowledged = await aiCancelStream(run.currentSessionId).catch(() => false);");
|
||||
expect(body).toContain("if (acknowledged) return;");
|
||||
expect(body).toContain("DESKTOP_CANCEL_ACK_RETRY_MS");
|
||||
});
|
||||
|
||||
it("stopDesktopAiRun fire-and-forgets the cancel retry so a hung IPC cannot block the bounded force-abandon", () => {
|
||||
// Reviewed finding (deadlock, HIGH): stopDesktopAiRun() awaited
|
||||
// requestDesktopRunCancellation(), which awaits aiCancelStream() directly.
|
||||
// If the IPC never returned, the STOP_FORCE_ABANDON_MS race was never read
|
||||
// and the stop never force-abandoned — a hung cancel RPC stranded the run
|
||||
// exactly like the hung stream it was meant to recover from.
|
||||
const body = bodyOf("async function stopDesktopAiRun(");
|
||||
expect(body).toContain("void requestDesktopRunCancellation(run, deadlineAt);");
|
||||
expect(body).not.toContain("await requestDesktopRunCancellation(run, deadlineAt);");
|
||||
// The bounded settle race drives the force-abandon, not the cancel RPC.
|
||||
expect(body).toContain("if (await settled) {");
|
||||
});
|
||||
|
||||
it("both stop entry points delegate to the shared stopDesktopAiRun helper", () => {
|
||||
const cancelBody = bodyOf("async function cancelStream()");
|
||||
expect(cancelBody).toContain("await stopDesktopAiRun(run);");
|
||||
expect(cancelBody).not.toContain('finishDesktopAiRun(run, "cancelled")');
|
||||
const rowBody = bodyOf("async function stopConversationRun(");
|
||||
expect(rowBody).toContain("await stopDesktopAiRun(run);");
|
||||
});
|
||||
|
||||
it("keeps long task summaries out of the fixed-width history popover", () => {
|
||||
// The history popover is always w-72, but min-[430px] responds to viewport
|
||||
// width. On desktop it displayed long summaries inside the compact row,
|
||||
// squeezing the conversation title and forcing horizontal scrolling. The
|
||||
// status icon still exposes the full text through its title/ARIA metadata.
|
||||
const historyStart = source.indexOf('<PopoverContent align="end" class="w-72 gap-0 p-0"');
|
||||
const history = source.slice(historyStart);
|
||||
expect(history).toContain(":title=\"conversationRowDetail(conv).summary ?? t('ai.runStatusCompleted')\"");
|
||||
expect(history).toContain(":title=\"conversationRowDetail(conv).reason ?? t(conversationRowDetail(conv).status === 'interrupted' ? 'ai.runStatusInterrupted' : 'ai.runStatusFailed')\"");
|
||||
expect(history).not.toContain("conversationRowDetail(conv).summary }}</span>");
|
||||
expect(history).not.toContain("conversationRowDetail(conv).reason }}</span>");
|
||||
});
|
||||
});
|
||||
+3
-102
@@ -9,9 +9,9 @@ import { describe, expect, it } from "vitest";
|
||||
// connection back onto that tab — so every conversation shared one connection
|
||||
// and the composer's database selection leaked between chats.
|
||||
//
|
||||
// AiAssistant.vue is a ~6000-line SFC the suite never mounts, so these
|
||||
// assertions pin the wiring as text, matching the house source-assertion style
|
||||
// used by AiAssistant.chatTitle.spec.ts. The binding *rules* themselves are
|
||||
// AiAssistant.vue is a ~6000-line SFC the suite never mounts, so the checks
|
||||
// that keep AI writes and runs on the bound connection pin the wiring as text
|
||||
// until they have mounted coverage. The binding *rules* themselves are
|
||||
// unit-tested in lib/ai/__tests__/aiConversationBinding.spec.ts.
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
@@ -45,25 +45,6 @@ describe("AI conversation owns its connection binding (#9902)", () => {
|
||||
expect(body).toContain("rebindConversation(");
|
||||
});
|
||||
|
||||
it("persists a rebind onto the conversation record", () => {
|
||||
const body = bodyOf("async function rebindConversation(");
|
||||
|
||||
expect(body).toContain("connectionId: connection.id");
|
||||
expect(body).toContain("saveAiConversation(updated)");
|
||||
// A chat that has no record yet stages the choice until its first snapshot.
|
||||
expect(body).toContain("draftBinding.value =");
|
||||
});
|
||||
|
||||
it("resets the composer's database selection when the conversation changes", () => {
|
||||
// The key must include the conversation id: `selectedDatabases` is one ref
|
||||
// for the whole panel, so a key without it kept the previous chat's
|
||||
// database selected — the "bound database is not isolated" report.
|
||||
const watcherStart = source.indexOf("watch(\n () => `${boundConnectionId.value}");
|
||||
expect(watcherStart).toBeGreaterThanOrEqual(0);
|
||||
const watcher = source.slice(watcherStart, source.indexOf(");", watcherStart));
|
||||
expect(watcher).toContain("conversationId.value");
|
||||
});
|
||||
|
||||
it("sends against the bound connection, not the visible tab", () => {
|
||||
const body = bodyOf("async function send()");
|
||||
|
||||
@@ -82,55 +63,6 @@ describe("AI conversation owns its connection binding (#9902)", () => {
|
||||
expect(source).toContain("const productionContext = computed(() => productionContextOf(activeRunBinding.value));");
|
||||
});
|
||||
|
||||
it("clears a staged draft binding when the shown chat changes", () => {
|
||||
expect(bodyOf("function selectConversation(conv: AiConversation)")).toContain("draftBinding.value = null;");
|
||||
expect(bodyOf("function clearMessages()")).toContain("draftBinding.value = null;");
|
||||
});
|
||||
|
||||
it("shows the composer's connection and schema selectors from the binding", () => {
|
||||
expect(source).toContain(':model-value="boundConnectionId"');
|
||||
expect(source).toContain(":model-value=\"boundSchema || ''\"");
|
||||
expect(source).not.toContain(":model-value=\"connection?.id || ''\"");
|
||||
});
|
||||
|
||||
it("labels each history row with the connection it is bound to", () => {
|
||||
// Which database a conversation talks to has to be readable without opening
|
||||
// it — the binding is per conversation, and several can be live at once.
|
||||
expect(source).toContain("{{ conv.connectionName }}");
|
||||
expect(source).toContain("conversationRowDetail(conv).connectionMissing");
|
||||
expect(bodyOf("function conversationRowDetail(conv: AiConversation)")).toContain("connectionMissing: !!conv.connectionId && !connectionStore.getConfig(conv.connectionId)");
|
||||
});
|
||||
|
||||
it("retargets the conversation on a cross-connection table drop too", () => {
|
||||
// Reverses the old "reject a foreign table" contract: with the conversation
|
||||
// owning its binding, the drop can retarget it instead of being discarded.
|
||||
const start = source.indexOf("function onTableReferenceDropEvent");
|
||||
const body = source.slice(start, source.indexOf("\n}", start));
|
||||
|
||||
expect(body).toContain("void bindConversation({ connectionId: payload.connectionId, database: payload.database, schema: payload.schema })");
|
||||
expect(body).toContain("addSelectedMention(");
|
||||
expect(body).not.toContain("context:");
|
||||
});
|
||||
|
||||
it("retargets the conversation when a table is picked from another connection", () => {
|
||||
// Sliced between anchors rather than via bodyOf(): the parameter's
|
||||
// `{ schema?: string; table: string }` annotation is the first "{" after the
|
||||
// signature, so bodyOf would return the type, not the function body.
|
||||
const start = source.indexOf("function addTableMention(");
|
||||
const body = source.slice(start, source.indexOf("function clearContextReferences", start));
|
||||
|
||||
expect(body).toContain("void bindConversation(binding)");
|
||||
expect(body).toContain("addSelectedMention(");
|
||||
|
||||
const apply = bodyOf("async function bindConversation(binding: AiConversationBinding)");
|
||||
// The previous target's mentions and schema options no longer apply...
|
||||
expect(apply).toContain("clearContextReferences();");
|
||||
// ...and the new target goes onto the conversation, never the editor.
|
||||
expect(apply).toContain("rebindConversation(connection, binding.database, binding.schema)");
|
||||
expect(apply).not.toContain("queryStore.");
|
||||
expect(apply).not.toContain("activeConnectionId");
|
||||
});
|
||||
|
||||
it("freezes the binding for a background auto-send instead of reading the visible one", () => {
|
||||
// An auto-send for conversation A runs while B may be on screen, so it must
|
||||
// carry its own binding: reading the live one would target B's database.
|
||||
@@ -172,14 +104,6 @@ describe("AI conversation owns its connection binding (#9902)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("compares the whole namespace when deciding whether to retarget", () => {
|
||||
// Comparing only the connection id would treat db1 -> db2 on one server as
|
||||
// "no change" and skip the rebind.
|
||||
const body = bodyOf("async function bindConversation(binding: AiConversationBinding)");
|
||||
expect(body).toContain("sameConversationBinding(binding, conversationBinding.value)");
|
||||
expect(body).not.toContain("binding.connectionId === boundConnectionId.value");
|
||||
});
|
||||
|
||||
it("judges production write protection and routing against the run's frozen binding", () => {
|
||||
// A run's target is frozen at send time, so a rebind mid-run (or while the
|
||||
// confirmation card is up) must not move the production verdict or the
|
||||
@@ -219,29 +143,6 @@ describe("AI conversation owns its connection binding (#9902)", () => {
|
||||
expect(body).toContain("confirmationBindingForNextRun = runBinding;");
|
||||
});
|
||||
|
||||
it("keeps the snapshot's connection name and id on the same source", () => {
|
||||
// Taking the name from the caller (a run, so connection A) while the id comes
|
||||
// from the conversation record (B after a mid-run rebind) would save a
|
||||
// record that displays A but targets B.
|
||||
const body = bodyOf("function buildConversationSnapshot(");
|
||||
expect(body).toContain("connectionName: binding.connectionId ? (connectionStore.getConfig(binding.connectionId)?.name ?? connectionName) : connectionName,");
|
||||
expect(body).toContain("connectionId: binding.connectionId,");
|
||||
// The caller-supplied name is only a fallback now, never stored bare next to
|
||||
// an id taken from a different source.
|
||||
expect(body).toContain(": connectionName," + String.fromCharCode(10));
|
||||
});
|
||||
|
||||
it("uses a run's frozen target for the first desktop snapshot", () => {
|
||||
const persist = bodyOf("async function persistDesktopRunSnapshot(run: DesktopAiRunRuntime<ChatMessage>)");
|
||||
expect(persist).toContain("{ connectionId: run.connectionId, database: run.database, schema: run.schema }");
|
||||
const snapshot = bodyOf("function buildConversationSnapshot(");
|
||||
expect(snapshot).toContain("snapshotBinding(targetConversationId, fallbackBinding)");
|
||||
expect(snapshot).toContain("schema: binding.schema");
|
||||
// Leaving the new chat also invokes this ordinary save path before the
|
||||
// run's scheduled snapshot; it must use the active run's target too.
|
||||
expect(bodyOf("async function persistConversation()")).toContain("const binding = activeRunBinding.value;");
|
||||
});
|
||||
|
||||
it("persists the assistant turn's target for Web confirmation after remount", () => {
|
||||
expect(source).toContain("sourceBinding: runBinding");
|
||||
expect(bodyOf("function buildConversationSnapshot(")).toContain("sourceBinding: m.sourceBinding");
|
||||
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("AI assistant conversation export", () => {
|
||||
it("passes native save-dialog short extensions, never the bare format key", () => {
|
||||
expect(source).toContain('saveTextFile(result.content, result.defaultFileName, format === "markdown" ? "Markdown" : "HTML", format === "markdown" ? "md" : "html")');
|
||||
expect(source).not.toMatch(/saveTextFile\([^)]*,\s*format\)/);
|
||||
});
|
||||
|
||||
it("records the failure on the message state at the same point the prefix text is written", () => {
|
||||
expect(source).toContain("msg.failed = true;");
|
||||
});
|
||||
|
||||
it("persists the failed flag through both conversation snapshot paths", () => {
|
||||
// buildConversationSnapshot and persistPendingInputRecovery both whitelist
|
||||
// fields on the way to storage; serde drops unknown fields on the Rust
|
||||
// side, so a path that forgets `failed` silently loses the marker.
|
||||
expect(source).toContain("...(m.failed ? { failed: true } : {})");
|
||||
expect(source).toContain("failed: m.failed === true ? true : undefined");
|
||||
});
|
||||
|
||||
it("derives the export failure marker from persisted state and run status, not locale text", () => {
|
||||
expect(source).toContain("failed: msg.failed === true || (runFailed && msg === failedTurnAssistant)");
|
||||
expect(source).not.toContain('msg.content.startsWith(t("ai.requestFailed"))');
|
||||
});
|
||||
|
||||
it("attributes run-status failure only to an assistant reply after the latest user turn", () => {
|
||||
// Restart-before-first-delta regression: when a run is restored as
|
||||
// interrupted before producing any output, the last reply in the transcript
|
||||
// belongs to the PRECEDING successful turn — it must stay unmarked.
|
||||
expect(source).toContain("let lastUserIndex = -1;");
|
||||
expect(source).toContain("i > lastUserIndex");
|
||||
expect(source).toContain("failedTurnAssistant = candidate;");
|
||||
expect(source).not.toContain("lastVisibleAssistant");
|
||||
});
|
||||
|
||||
it("consults the run registry / fallback status map for background-failed turns", () => {
|
||||
expect(source).toContain("function isCurrentRunFailed()");
|
||||
expect(source).toContain('return status === "failed" || status === "interrupted";');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("AI assistant message wrapping", () => {
|
||||
it("allows long unbroken user text such as comma-separated SQL arrays to wrap", () => {
|
||||
expect(source).toContain('data-ai-user-message-content class="whitespace-pre-wrap">{{ msg.content }}</div>');
|
||||
expect(source).toContain(".ai-message-scroll :deep([data-ai-user-message-content])");
|
||||
expect(source).toContain("overflow-wrap: anywhere;");
|
||||
});
|
||||
|
||||
it("allows long unbroken fenced code to wrap inside the message bubble", () => {
|
||||
expect(source).toContain('class="ai-code-block whitespace-pre-wrap break-words [overflow-wrap:anywhere]');
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Regression coverage for the AI status ring on the scroll-to-bottom button:
|
||||
//
|
||||
// While a run is active for the visible conversation (`isGenerating`), the
|
||||
// scroll-to-bottom button must stay visible even when the user is already at
|
||||
// the bottom, and must render a decorative rotating arc ring (an "assistant is
|
||||
// working" marker, Si-Octo-style). The ring is aria-hidden decoration driven
|
||||
// purely by `isGenerating` — the scroll-driven hysteresis (`showScrollToBottom`
|
||||
// + the MESSAGE_SCROLL_BUTTON_* thresholds) must stay the single source of the
|
||||
// scroll path, and clicking must still force-scroll to the bottom.
|
||||
//
|
||||
// AiAssistant.vue is a ~6000-line SFC the suite never mounts (see
|
||||
// .trellis/spec/app-tests/frontend/component-guidelines.md): these assertions
|
||||
// pin the template wiring as text, matching the house source-assertion style.
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
const buttonMatch = source.match(/<button[^>]*v-if="showScrollToBottom \|\| isGenerating"[\s\S]*?<\/button>/);
|
||||
|
||||
function buttonEl(): string {
|
||||
if (!buttonMatch) {
|
||||
throw new Error('scroll-to-bottom button gated on "showScrollToBottom || isGenerating" not found in AiAssistant.vue');
|
||||
}
|
||||
return buttonMatch[0];
|
||||
}
|
||||
|
||||
describe("AI assistant scroll-to-bottom button status ring", () => {
|
||||
it("keeps the button visible while a run is active, not only when scrolled away", () => {
|
||||
expect(buttonEl()).toContain('v-if="showScrollToBottom || isGenerating"');
|
||||
});
|
||||
|
||||
it("renders the decorative rotating arc ring only while generating", () => {
|
||||
const button = buttonEl();
|
||||
const ring = button.match(/<svg[\s\S]*?<\/svg>/);
|
||||
if (!ring) throw new Error("expected the inline status-ring <svg> inside the scroll-to-bottom button");
|
||||
const ringEl = ring[0];
|
||||
expect(ringEl).toContain('v-if="isGenerating"');
|
||||
// Decorative: flagged for tests/e2e, excluded from the accessibility tree.
|
||||
expect(ringEl).toContain("data-ai-scroll-ring");
|
||||
expect(ringEl).toContain('aria-hidden="true"');
|
||||
// House spinner idiom + the firm prefers-reduced-motion opt-out convention.
|
||||
expect(ringEl).toContain("animate-spin");
|
||||
expect(ringEl).toContain("motion-reduce:animate-none");
|
||||
// Semantic token color — theme/dark-mode support comes free via currentColor.
|
||||
expect(ringEl).toContain("text-primary");
|
||||
// The ring must never swallow clicks, and fill="none" keeps the open arc
|
||||
// from rendering as a filled pie slice.
|
||||
expect(ringEl).toContain("pointer-events-none");
|
||||
expect(ringEl).toContain('fill="none"');
|
||||
// Open ~288° arc (statement-gutter spinner geometry scaled to the 32-unit
|
||||
// ring box), round-capped like every other arc/spinner in the repo.
|
||||
expect(ringEl).toContain("M29 16a13 13 0 1 1-8.98-12.36");
|
||||
expect(ringEl).toContain('stroke-linecap="round"');
|
||||
});
|
||||
|
||||
it("keeps the force scroll-to-bottom behavior and the arrow icon intact", () => {
|
||||
const button = buttonEl();
|
||||
expect(button).toContain("scrollToBottom({ force: true })");
|
||||
expect(button).toContain("<ArrowDown");
|
||||
expect(button).toContain('t("ai.scrollToBottom")');
|
||||
});
|
||||
|
||||
it("leaves the scroll hysteresis thresholds untouched", () => {
|
||||
// Visibility is an OR over the existing state machine; the thresholds must
|
||||
// remain the single source of the scroll-driven path (show 120 > hide 48).
|
||||
expect(source).toContain("MESSAGE_SCROLL_BUTTON_SHOW_THRESHOLD_PX = 120;");
|
||||
expect(source).toContain("MESSAGE_SCROLL_BUTTON_HIDE_THRESHOLD_PX = 48;");
|
||||
});
|
||||
});
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Regression coverage for https://github.com/t8y2/dbx/issues/7649: per-db_type
|
||||
// prompt template defaults + last-used fallback. The resolution rules live in
|
||||
// lib/ai/promptTemplateDefaults.ts (unit-tested separately); this file pins
|
||||
// that AiAssistant.vue wires them into the panel lifecycle:
|
||||
// - auto-apply once when the AI selection + templates are loaded,
|
||||
// - re-resolve on connection/database/schema switch (defaults only opt-in),
|
||||
// - record what was actually sent as the db_type's last-used templates.
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("AI assistant applies per-db_type prompt template defaults", () => {
|
||||
it("panel-open resolution uses defaults-else-last-used through the tested helper and enforces the char cap", () => {
|
||||
const maybeBody = source.slice(source.indexOf("async function maybeApplyAutoTemplates()"), source.indexOf("// Reset template selection when the user switches"));
|
||||
expect(maybeBody).toContain("resolveAutoTemplateIds({");
|
||||
expect(maybeBody).toContain("defaultTemplatesByDbType: settings.aiDefaultTemplatesByDbType");
|
||||
expect(maybeBody).toContain("lastUsedTemplatesByDbType: settings.aiLastUsedTemplatesByDbType");
|
||||
expect(maybeBody).toContain("applyResolvedTemplateIds(");
|
||||
// The char cap lives in the shared apply helper both paths funnel through.
|
||||
const applyBody = source.slice(source.indexOf("function applyResolvedTemplateIds("), source.indexOf("async function maybeApplyAutoTemplates()"));
|
||||
expect(applyBody).toContain("capTemplateIdsToCharLimit(ids, promptTemplateStore.templates, ACTIVE_TEMPLATES_TOTAL_MAX)");
|
||||
});
|
||||
|
||||
it("auto-apply waits for the AI selection load, runs once, and skips failed template loads", () => {
|
||||
const maybeBody = source.slice(source.indexOf("async function maybeApplyAutoTemplates()"), source.indexOf("// Reset template selection when the user switches"));
|
||||
expect(maybeBody).toContain("if (autoTemplatesInitialized || !settings.isAiConfigLoaded) return;");
|
||||
expect(maybeBody).toContain("if (!(await promptTemplateStore.ensureLoaded())) return;");
|
||||
expect(maybeBody).toContain("autoTemplatesInitialized = true;");
|
||||
expect(maybeBody).toContain("applyResolvedTemplateIds(");
|
||||
});
|
||||
|
||||
it("namespace switch re-resolves defaults only when both stores are loaded, otherwise clears", () => {
|
||||
const watcherBody = source.slice(source.indexOf("aiTemplateNamespaceKey,"), source.indexOf("function toggleTemplateId("));
|
||||
expect(watcherBody).toContain("if (!settings.isAiConfigLoaded || !promptTemplateStore.isLoaded) {");
|
||||
expect(watcherBody).toContain("activeTemplateIds.value = [];");
|
||||
// The switch path must resolve through the defaults-only helper; the
|
||||
// mount resolver (defaults-else-last-used) would resurrect cleared
|
||||
// selections. Pin the exact call so an indirect last-used fallback
|
||||
// cannot hide behind a shared helper again.
|
||||
expect(watcherBody).toContain("resolveDefaultTemplateIds(templateDbType.value, settings.aiDefaultTemplatesByDbType)");
|
||||
expect(watcherBody).not.toContain("resolveAutoTemplateIds");
|
||||
expect(watcherBody).not.toContain("lastUsedTemplatesByDbType");
|
||||
});
|
||||
|
||||
it("keys defaults, last-used, and the badge by the effective AI database type", () => {
|
||||
// Same axis aiDatabaseTypeForConnection established for schema selection:
|
||||
// gbase → mysql, doris-over-mysql protocol → doris, jdbc → inferred
|
||||
// dialect. Raw db_type keying would never match for those connections.
|
||||
expect(source).toContain("const templateDbType = computed(() => (!pluginContext.value && boundConnection.value ? aiDatabaseTypeForConnection(boundConnection.value) : undefined));");
|
||||
expect(source).toContain("aiDatabaseTypeForConnection");
|
||||
});
|
||||
|
||||
it("panel-open resolution bails when the namespace switched during the load await", () => {
|
||||
const maybeBody = source.slice(source.indexOf("async function maybeApplyAutoTemplates"), source.indexOf("watch(", source.indexOf("async function maybeApplyAutoTemplates")));
|
||||
expect(maybeBody).toContain("const namespaceAtStart = aiTemplateNamespaceKey.value;");
|
||||
expect(maybeBody).toContain("if (namespaceAtStart !== aiTemplateNamespaceKey.value) return;");
|
||||
});
|
||||
|
||||
it("selector-open load retry also applies pending defaults", () => {
|
||||
const watcherBody = source.slice(source.indexOf("watch(showTemplateSelector"), source.indexOf("// Auto-apply per-db_type"));
|
||||
expect(watcherBody).toContain("void promptTemplateStore.ensureLoaded().then(() => void maybeApplyAutoTemplates());");
|
||||
});
|
||||
|
||||
it("send records the sent templates as the db_type's last-used selection", () => {
|
||||
const sendIdx = source.indexOf("activeTemplates: runPluginContext ? [] : [...activeTemplates.value],");
|
||||
const recordIdx = source.indexOf("settings.recordLastUsedTemplates(templateDbType.value, [...activeTemplateIds.value]);");
|
||||
expect(sendIdx).toBeGreaterThan(-1);
|
||||
expect(recordIdx).toBeGreaterThan(sendIdx);
|
||||
// No length guard: an empty selection must also reach the store so the
|
||||
// remembered entry is cleared instead of resurrecting old templates.
|
||||
const guard = source.slice(sendIdx, recordIdx);
|
||||
expect(guard).toContain("if (templateDbType.value) {");
|
||||
expect(guard).not.toContain("activeTemplates.value.length > 0");
|
||||
});
|
||||
|
||||
it("the template selector marks defaults for the current connection db_type", () => {
|
||||
expect(source).toContain('v-if="isDefaultTemplateForCurrentDb(tpl.id)"');
|
||||
expect(source).toContain("settings.aiDefaultTemplatesByDbType[dbType]?.includes(id) ?? false");
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,6 @@ import { createQueryEditorSearchKeymap, replaceFallbackKey } from "@/lib/editor/
|
||||
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");
|
||||
const nacosConsoleSource = readFileSync(new URL("../../nacos/NacosAdminConsole.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("EditorSearchPanel corner style", () => {
|
||||
it("uses the configurable five-pixel radius token for editor inputs", () => {
|
||||
expect(editorSearchPanelSource).toContain("border-radius: var(--dbx-radius-fixed-5);");
|
||||
});
|
||||
});
|
||||
|
||||
describe("QueryEditor search shortcuts", () => {
|
||||
it("opens search and replace in editable editors", () => {
|
||||
@@ -60,11 +53,6 @@ describe("QueryEditor search shortcuts", () => {
|
||||
expect(replaceFallbackKey("Linux x86_64")).toBe("Mod-h");
|
||||
});
|
||||
|
||||
it("routes the Nacos console replace fallback through the same platform helper", () => {
|
||||
expect(nacosConsoleSource).toContain("key: replaceFallbackKey()");
|
||||
expect(nacosConsoleSource).not.toContain('key: "Mod-h"');
|
||||
});
|
||||
|
||||
it("allows search but consumes replace without opening it in read-only editors", () => {
|
||||
const openSearch = vi.fn(() => true);
|
||||
const openReplace = vi.fn(() => true);
|
||||
@@ -87,18 +75,4 @@ describe("QueryEditor search shortcuts", () => {
|
||||
expect(editorSearchPanelSource).toContain('e.key === "Enter" && !e.shiftKey');
|
||||
expect(editorSearchPanelSource).toContain('e.key === "Enter" && e.shiftKey');
|
||||
});
|
||||
|
||||
it("registers the find binding before the formatSql binding so an occupied default would shadow formatSql", () => {
|
||||
// The runtime keymap is first-match-wins: the find binding (shortcuts.find,
|
||||
// Mod+F) is spread into QueryEditor.vue's Prec.high keymap before the
|
||||
// formatSql binding (shortcuts.formatSql). A reserved-key repair that reset
|
||||
// find to its default Mod+F while the user had explicitly bound formatSql to
|
||||
// Mod+F would make formatSql unreachable — this ordering is why
|
||||
// normalizeShortcutSettings clears such a repair instead of applying it.
|
||||
const findIndex = queryEditorSource.indexOf("...binding(shortcuts.find, openSearch),");
|
||||
const formatIndex = queryEditorSource.indexOf("...binding(shortcuts.formatSql,");
|
||||
expect(findIndex).toBeGreaterThan(-1);
|
||||
expect(formatIndex).toBeGreaterThan(-1);
|
||||
expect(findIndex).toBeLessThan(formatIndex);
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
-35
@@ -1,35 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
|
||||
|
||||
// Regression for https://github.com/t8y2/dbx/issues/7874: after using an AI
|
||||
// config deep link (dbx://settings/ai/new?...) once, every later "open
|
||||
// Settings" (even a plain click on the gear icon) got forcibly yanked back to
|
||||
// the AI tab. Root cause: `settingsAiConfigDraft`/`settingsAiConfigRequestId`
|
||||
// in App.vue are set once by `openAiConfigDeepLink` and never cleared, while
|
||||
// the dialog's own "already handled this request" guard
|
||||
// (`handledAiConfigRequestId`) is a plain local variable that resets to 0
|
||||
// every time the dialog remounts (it's `v-if`-toggled in App.vue, so closing
|
||||
// Settings destroys the instance). On the next open, `requestId !==
|
||||
// handledAiConfigRequestId` is true again even though nothing new happened,
|
||||
// so `applyPendingAiConfigDeepLinkDraft` reapplies the stale draft and jumps
|
||||
// the user back into "ai" tab / edit mode forever, once per app run.
|
||||
describe("EditorSettingsDialog AI config deep-link tab hijack", () => {
|
||||
function applyDraftBlock(): string {
|
||||
const start = dialogSource.indexOf("async function applyPendingAiConfigDeepLinkDraft()");
|
||||
const end = dialogSource.indexOf("\n}\n", start);
|
||||
if (start < 0 || end < 0) throw new Error("Missing applyPendingAiConfigDeepLinkDraft function block");
|
||||
return dialogSource.slice(start, end);
|
||||
}
|
||||
|
||||
it("emits an event once the deep-link draft has been consumed", () => {
|
||||
expect(dialogSource).toContain('"ai-config-deep-link-handled": []');
|
||||
expect(applyDraftBlock()).toContain('emit("ai-config-deep-link-handled")');
|
||||
});
|
||||
|
||||
it("App.vue clears the draft/request state once notified, so a remounted dialog can't reapply it", () => {
|
||||
expect(appSource).toContain('@ai-config-deep-link-handled="settingsAiConfigDraft = null"');
|
||||
});
|
||||
});
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("EditorSettingsDialog AI test result isolation", () => {
|
||||
it("clears the previous test result when opening another configuration", () => {
|
||||
expect(dialogSource).toMatch(/function aiEnterEditMode\(configId\?: string\) \{\s*syncAiEditState\(\);/);
|
||||
});
|
||||
|
||||
it("clears the previous test result when changing provider in the editor", () => {
|
||||
expect(dialogSource).toMatch(/if \(presetId === aiEditProviderPresetId\.value\) return;\s*\n\s*syncAiEditState\(\);/);
|
||||
});
|
||||
});
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
// Regression for https://github.com/t8y2/dbx/issues/6485: applying editor
|
||||
// settings (e.g. the editor font) could appear to "do nothing" because
|
||||
// persistSettings() has several awaited persistence steps with no error
|
||||
// handling. On a rejected save, "Apply" was completely silent and "Apply &
|
||||
// Close" never called closeSettings(), leaving the dialog stuck open with no
|
||||
// feedback at all.
|
||||
describe("EditorSettingsDialog apply persistence error handling", () => {
|
||||
it("shares a single apply entrypoint between Apply and Apply & Close", () => {
|
||||
// Both buttons must go through one shared persist/apply routine instead of
|
||||
// each calling persistSettings() directly with divergent semantics.
|
||||
const block = dialogSource.slice(dialogSource.indexOf("function applySettingsErrorToast"), dialogSource.indexOf("async function restartDbxForDuckDbIsolation()"));
|
||||
const entrypointCalls = (block.match(/await applySettingsForResult\(\)/g) || []).length;
|
||||
// one call in applySettings() + one (guarded) call in applySettingsAndClose()
|
||||
expect(entrypointCalls).toBeGreaterThanOrEqual(2);
|
||||
// the shared entrypoint exists and wraps persistSettings
|
||||
expect(block).toContain("async function applySettingsForResult(): Promise<boolean>");
|
||||
expect(block).toContain("await persistSettings();");
|
||||
});
|
||||
|
||||
it("never closes the dialog before persistence succeeds", () => {
|
||||
// "Apply & Close" must only proceed to closeSettings() after a successful
|
||||
// apply. Before the fix, closeSettings() ran unconditionally after
|
||||
// `await persistSettings()`, so a rejected save left the dialog stuck open.
|
||||
const block = dialogSource.slice(dialogSource.indexOf("async function applySettingsAndClose()"), dialogSource.indexOf("async function restartDbxForDuckDbIsolation()"));
|
||||
expect(block).toMatch(/if \(await applySettingsForResult\(\)\) \{\s*\n\s*closeSettings\(\);/);
|
||||
});
|
||||
|
||||
it("surfaces persistence failures instead of silently swallowing them", () => {
|
||||
const resultBlock = dialogSource.slice(dialogSource.indexOf("async function applySettingsForResult()"), dialogSource.indexOf("async function applySettings()"));
|
||||
expect(resultBlock).toContain("try {");
|
||||
expect(resultBlock).toContain("catch (error)");
|
||||
expect(resultBlock).toContain("applySettingsErrorToast(error)");
|
||||
expect(resultBlock).toContain("return false;");
|
||||
// a user-facing message is wired up (and translated in every locale)
|
||||
expect(dialogSource).toContain('toast(t("settings.applyFailed"');
|
||||
});
|
||||
});
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cn } from "@/lib/common/utils";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
const buttonSource = readFileSync(new URL("../../ui/button/index.ts", import.meta.url), "utf8");
|
||||
const templateSource = dialogSource.slice(dialogSource.indexOf("<template>"));
|
||||
const buttonBaseClass = /cva\(\s*"([^"]+)"/.exec(buttonSource)?.[1];
|
||||
if (!buttonBaseClass) throw new Error("Missing shared Button base class contract");
|
||||
|
||||
const affectedChoiceKeys = ["tabLayoutScroll", "tabLayoutWrap", "dataTabReuseAlwaysNew", "dataTabReuseSameTable", "dataTabReuseActiveTab", "routineSourceOpenModeQueryTab", "routineSourceOpenModeDialog"] as const;
|
||||
|
||||
function sourceIndexForKey(key: string): number {
|
||||
const index = templateSource.indexOf(`t("settings.${key}")`);
|
||||
if (index < 0) throw new Error(`Missing settings key: ${key}`);
|
||||
return index;
|
||||
}
|
||||
|
||||
function buttonBlockForKey(key: string): string {
|
||||
const keyIndex = sourceIndexForKey(key);
|
||||
const start = templateSource.lastIndexOf("<Button", keyIndex);
|
||||
const end = templateSource.indexOf("</Button>", keyIndex);
|
||||
if (start < 0 || end < 0) throw new Error(`Missing Button for settings key: ${key}`);
|
||||
return templateSource.slice(start, end + "</Button>".length);
|
||||
}
|
||||
|
||||
function openingTag(source: string, element: string): string {
|
||||
const start = source.indexOf(`<${element}`);
|
||||
const end = tagEnd(source, start);
|
||||
if (start < 0 || end < 0) throw new Error(`Missing <${element}> opening tag`);
|
||||
return source.slice(start, end + 1);
|
||||
}
|
||||
|
||||
function elementTagForKey(key: string, element = "div"): string {
|
||||
const keyIndex = sourceIndexForKey(key);
|
||||
const start = templateSource.lastIndexOf(`<${element}`, keyIndex);
|
||||
const end = tagEnd(templateSource, start);
|
||||
if (start < 0 || end < 0) throw new Error(`Missing <${element}> for settings key: ${key}`);
|
||||
return templateSource.slice(start, end + 1);
|
||||
}
|
||||
|
||||
function elementTagForKeyInButton(buttonKey: string, key: string, element = "div"): string {
|
||||
const block = buttonBlockForKey(buttonKey);
|
||||
const keyIndex = block.indexOf(`t("settings.${key}")`);
|
||||
const start = block.lastIndexOf(`<${element}`, keyIndex);
|
||||
const end = tagEnd(block, start);
|
||||
if (keyIndex < 0 || start < 0 || end < 0) throw new Error(`Missing <${element}> for settings key ${key} in ${buttonKey}`);
|
||||
return block.slice(start, end + 1);
|
||||
}
|
||||
|
||||
function tagEnd(source: string, start: number): number {
|
||||
let quote: '"' | "'" | null = null;
|
||||
for (let index = start; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (quote) {
|
||||
if (char === quote && source[index - 1] !== "\\") quote = null;
|
||||
} else if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
} else if (char === ">") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function classNameFromTag(tag: string): string {
|
||||
const match = /\sclass="([^"]+)"/.exec(tag);
|
||||
if (!match) throw new Error(`Missing static class attribute in: ${tag}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function expectClassTokens(className: string, tokens: readonly string[]) {
|
||||
const classes = new Set(className.split(/\s+/));
|
||||
for (const token of tokens) expect(classes.has(token), `${className} should include ${token}`).toBe(true);
|
||||
}
|
||||
|
||||
describe("EditorSettingsDialog choice card containment", () => {
|
||||
it("shows the persistent filter-editor preference only for fixed filter views", () => {
|
||||
const key = 't("settings.dataGridKeepFilterEditorExpanded")';
|
||||
const keyIndex = templateSource.indexOf(key);
|
||||
const configurationStart = templateSource.indexOf("<div v-if=\"editDataGridFilterEditorView !== 'quick'\"");
|
||||
expect(keyIndex).toBeGreaterThan(-1);
|
||||
expect(configurationStart).toBeGreaterThan(-1);
|
||||
expect(configurationStart).toBeLessThan(keyIndex);
|
||||
expect(templateSource).not.toContain("dataGridAutoHideFilterBuilder");
|
||||
});
|
||||
|
||||
it("overrides the shared Button nowrap contract on two- and three-column cards", () => {
|
||||
for (const key of affectedChoiceKeys) {
|
||||
const cardClass = classNameFromTag(openingTag(buttonBlockForKey(key), "Button"));
|
||||
expectClassTokens(cardClass, ["settings-choice-card", "min-w-0", "whitespace-normal", "overflow-hidden"]);
|
||||
|
||||
const mergedClass = cn(buttonBaseClass, cardClass);
|
||||
expectClassTokens(mergedClass, ["min-w-0", "whitespace-normal", "overflow-hidden"]);
|
||||
expect(mergedClass.split(/\s+/)).not.toContain("whitespace-nowrap");
|
||||
}
|
||||
});
|
||||
|
||||
it("lets long two-column descriptions wrap inside a shrinkable text container", () => {
|
||||
for (const key of ["tabLayoutScroll", "tabLayoutWrap", "routineSourceOpenModeQueryTab", "routineSourceOpenModeDialog"] as const) {
|
||||
const block = buttonBlockForKey(key);
|
||||
expect(block).toMatch(/<div class="[^"]*\bmin-w-0\b[^"]*\btext-left\b[^"]*">/);
|
||||
}
|
||||
|
||||
for (const [buttonKey, descriptionKey] of [
|
||||
["tabLayoutScroll", "tabLayoutScrollDescription"],
|
||||
["tabLayoutWrap", "tabLayoutWrapDescription"],
|
||||
["routineSourceOpenModeQueryTab", "routineSourceOpenModeQueryTabDescription"],
|
||||
["routineSourceOpenModeDialog", "routineSourceOpenModeDialogDescription"],
|
||||
] as const) {
|
||||
expectClassTokens(classNameFromTag(elementTagForKeyInButton(buttonKey, descriptionKey)), ["whitespace-normal", "break-words"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps three-column titles shrinkable without allowing help icons to collapse", () => {
|
||||
for (const key of ["dataTabReuseAlwaysNew", "dataTabReuseSameTable", "dataTabReuseActiveTab"] as const) {
|
||||
const block = buttonBlockForKey(key);
|
||||
expect(block).toMatch(/<div class="[^"]*\bmin-w-0\b[^"]*\btext-left\b[^"]*">/);
|
||||
expect(block).toMatch(/<div class="[^"]*\bflex\b[^"]*\bmin-w-0\b[^"]*\bitems-center\b[^"]*">/);
|
||||
expectClassTokens(classNameFromTag(elementTagForKey(key)), ["min-w-0", "break-words"]);
|
||||
expect(block).toMatch(/<span class="[^"]*\bshrink-0\b[^"]*cursor-help[^"]*"[^>]*>/);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves intentional single-line truncation for cards with tooltips", () => {
|
||||
for (const key of ["appLayoutSeparatedDescription", "appLayoutClassicDescription"] as const) {
|
||||
expectClassTokens(classNameFromTag(elementTagForKey(key)), ["truncate"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps large icon theme choices in appearance and debug logs in About", () => {
|
||||
expect(sourceIndexForKey("iconTheme")).toBeLessThan(sourceIndexForKey("debugLoggingEnabled"));
|
||||
expect(templateSource).toContain("data-icon-theme-settings");
|
||||
for (const key of ["iconThemeDefault", "iconThemeBlack"] as const) {
|
||||
const block = buttonBlockForKey(key);
|
||||
expectClassTokens(classNameFromTag(openingTag(block, "Button")), ["settings-choice-card", "h-auto", "min-w-0", "whitespace-normal", "overflow-hidden"]);
|
||||
expect(block).toContain('class="h-12 w-12 shrink-0"');
|
||||
}
|
||||
});
|
||||
});
|
||||
-98
@@ -4,104 +4,6 @@ import { createMcpQueryTimeoutHarness } from "./mcpQueryTimeoutHarness";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
function handlerBlock(): string {
|
||||
const start = dialogSource.indexOf("function onMcpQueryTimeoutInput(");
|
||||
const end = dialogSource.indexOf("const mcpSelectableConnections", start);
|
||||
if (start < 0 || end < 0) throw new Error("Missing onMcpQueryTimeoutInput handler block");
|
||||
return dialogSource.slice(start, end);
|
||||
}
|
||||
|
||||
// Regression for the MCP query-timeout input: decimal values like 1.5 were
|
||||
// silently Math.round-ed to 2 and saved, contradicting the "non-negative whole
|
||||
// number" label. The UI must reject non-integers with the existing invalid
|
||||
// toast instead of silently rewriting them.
|
||||
describe("EditorSettingsDialog MCP query timeout validation", () => {
|
||||
it("rejects non-integers instead of rounding them", () => {
|
||||
const block = handlerBlock();
|
||||
// The invalid path must test for integer-ness, not just finite + non-negative.
|
||||
expect(block).toMatch(/Number\.isInteger\(parsed\)/);
|
||||
// The valid save path must NOT wrap the value in Math.round anymore.
|
||||
expect(block).not.toContain("Math.round(parsed)");
|
||||
// A decimal input falls through to the invalid toast + revert branch.
|
||||
expect(block).toContain('toast(t("settings.mcpQueryTimeoutInvalid"), 5000)');
|
||||
expect(block).toMatch(/mcpQueryTimeoutInput\.value\s*=\s*reverted/);
|
||||
});
|
||||
|
||||
it("keeps null (inherit) and zero (no limit) working", () => {
|
||||
const block = handlerBlock();
|
||||
// Empty input still schedules null (inherit the connection).
|
||||
expect(block).toContain("mcpQueryTimeoutPendingValue = null");
|
||||
// A valid integer is scheduled with the exact value.
|
||||
expect(block).toContain("mcpQueryTimeoutPendingValue = parsed");
|
||||
// The invalid branch cancels any pending save instead of persisting garbage.
|
||||
expect(block).toContain("mcpQueryTimeoutPendingValue = undefined");
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for #8616: the input previously bound @change (fires only on
|
||||
// blur/Enter), so typing a value and closing the window dropped the change and
|
||||
// the persisted policy kept its old value. The fix binds @input (fires on every
|
||||
// keystroke), debounces the write, and flushes any pending value on close.
|
||||
describe("EditorSettingsDialog MCP query timeout persistence", () => {
|
||||
it("binds @input instead of @change so typing schedules a save immediately", () => {
|
||||
const inputLine = dialogSource.split("\n").find((line) => line.includes('id="mcp-query-timeout"'));
|
||||
expect(inputLine).toBeTruthy();
|
||||
// The legacy @change binding must not come back.
|
||||
expect(inputLine).not.toContain("@change");
|
||||
// Typing must trigger the save path on every keystroke.
|
||||
expect(inputLine).toContain('@input.capture="onMcpQueryTimeoutInput"');
|
||||
});
|
||||
|
||||
it("uses capture-phase input handling so the handler reads the fresh native value before Input's passive v-model update", () => {
|
||||
const inputLine = dialogSource.split("\n").find((line) => line.includes('id="mcp-query-timeout"'));
|
||||
expect(inputLine).toBeTruthy();
|
||||
expect(inputLine).toContain('v-model="mcpQueryTimeoutInput"');
|
||||
expect(inputLine).toContain('@input.capture="onMcpQueryTimeoutInput"');
|
||||
});
|
||||
|
||||
it("handler reads the value from the native event target, not the ref", () => {
|
||||
const block = handlerBlock();
|
||||
expect(block).toContain("onMcpQueryTimeoutInput(event: Event)");
|
||||
expect(block).toMatch(/const target = event\.currentTarget as HTMLInputElement/);
|
||||
expect(block).toContain("target.value.trim()");
|
||||
});
|
||||
|
||||
it("debounces the persist so rapid typing coalesces into one write", () => {
|
||||
const block = handlerBlock();
|
||||
expect(block).toContain("MCP_QUERY_TIMEOUT_SAVE_DEBOUNCE_MS");
|
||||
expect(block).toMatch(/setTimeout\([\s\S]*MCP_QUERY_TIMEOUT_SAVE_DEBOUNCE_MS\)/);
|
||||
expect(block).toContain("clearTimeout(mcpQueryTimeoutSaveTimer)");
|
||||
expect(block).toContain("flushMcpQueryTimeoutSave()");
|
||||
});
|
||||
|
||||
it("flushes the pending value when the settings dialog closes", () => {
|
||||
const closeBlockStart = dialogSource.indexOf("function requestCloseSettings(");
|
||||
const closeBlockEnd = dialogSource.indexOf("function onSettingsRootOpenChange", closeBlockStart);
|
||||
expect(closeBlockStart).toBeGreaterThan(-1);
|
||||
expect(closeBlockEnd).toBeGreaterThan(closeBlockStart);
|
||||
const closeBlock = dialogSource.slice(closeBlockStart, closeBlockEnd);
|
||||
expect(closeBlock).toContain("flushMcpQueryTimeoutSave()");
|
||||
|
||||
// The unmount path must also flush, covering a hard window close.
|
||||
const unmountIndex = dialogSource.lastIndexOf("onUnmounted(() =>");
|
||||
expect(unmountIndex).toBeGreaterThan(-1);
|
||||
const unmountTail = dialogSource.slice(unmountIndex);
|
||||
expect(unmountTail).toContain("flushMcpQueryTimeoutSave()");
|
||||
});
|
||||
|
||||
it("persists through saveMcpPolicy with the pending value", () => {
|
||||
const flushBlockStart = dialogSource.indexOf("function flushMcpQueryTimeoutSave()");
|
||||
const flushBlockEnd = dialogSource.indexOf("const mcpSelectableConnections", flushBlockStart);
|
||||
expect(flushBlockStart).toBeGreaterThan(-1);
|
||||
expect(flushBlockEnd).toBeGreaterThan(flushBlockStart);
|
||||
const flushBlock = dialogSource.slice(flushBlockStart, flushBlockEnd);
|
||||
// The debounced timer hands the pending value to the existing save path.
|
||||
expect(flushBlock).toContain("queryTimeoutSecs: value");
|
||||
// A cleared pending value (no recent input) is a no-op.
|
||||
expect(flushBlock).toContain("mcpQueryTimeoutPendingValue === undefined");
|
||||
});
|
||||
});
|
||||
|
||||
// Behavioral coverage for the debounce/flush runtime (review finding): the
|
||||
// string assertions above cannot catch a broken runtime path, so this harness
|
||||
// extracts the real debounce block from the .vue source, compiles it, and
|
||||
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("EditorSettingsDialog preview cleanup", () => {
|
||||
it("keeps the existing draft lifecycle independent from page activity", () => {
|
||||
expect(dialogSource).toContain("const settingsVisible = computed(() => isSettingsPage.value || props.open === true);");
|
||||
expect(dialogSource).toContain("syncEditorSettingsDraftFromStore();");
|
||||
});
|
||||
|
||||
it("clears previews when a mounted settings page becomes inactive", () => {
|
||||
expect(appSource).toContain('v-if="settingsPageTabOpen"');
|
||||
expect(appSource).toContain('v-show="settingsStore.settingsPageActive"');
|
||||
expect(dialogSource).toContain("() => settingsStore.settingsPageActive");
|
||||
expect(dialogSource).toContain("if (isSettingsPage.value && !active)");
|
||||
expect(dialogSource).toContain("clearThemePalettePreview();");
|
||||
expect(dialogSource).toContain("clearUiFontFamilyPreview();");
|
||||
expect(dialogSource).toContain("restoreLocaleOptionPreview();");
|
||||
});
|
||||
|
||||
it("coalesces pointer previews while keeping keyboard previews immediate", () => {
|
||||
expect(dialogSource).toContain('@update:open="onLocaleOpenChange"');
|
||||
expect(dialogSource).toContain('@pointerleave="restoreLocaleOptionPreview"');
|
||||
expect(dialogSource).toContain('@pointerenter="scheduleLocaleOptionPreview(locale.value)"');
|
||||
expect(dialogSource).toContain('@focus="previewLocaleOption(locale.value)"');
|
||||
expect(dialogSource).toContain('@pointerenter="scheduleThemePalettePreview(option.value)"');
|
||||
expect(dialogSource).toContain('@focus="previewThemePaletteOption(option.value)"');
|
||||
expect(dialogSource).toContain('@option-hover="scheduleUiFontOptionPreview"');
|
||||
expect(dialogSource).toContain('@option-highlight="previewUiFontOption"');
|
||||
expect(dialogSource).toContain("createDelayedPreview");
|
||||
});
|
||||
|
||||
it("keeps scale as an unapplied draft", () => {
|
||||
expect(dialogSource).toContain('@update:model-value="onUiScaleChange"');
|
||||
expect(dialogSource).not.toContain("useUiScalePreview");
|
||||
expect(dialogSource).not.toContain("previewUiScaleOption");
|
||||
expect(dialogSource).not.toContain("createUiScalePreviewRestore");
|
||||
expect(appSource).toContain("createUiScaleApplyQueue");
|
||||
expect(appSource).not.toContain("useUiScalePreview");
|
||||
expect(appSource).toContain("() => settingsStore.editorSettings.uiScale");
|
||||
expect(appSource).toContain("applyUiScale(scale)");
|
||||
});
|
||||
});
|
||||
-222
@@ -1,222 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
const editorSectionStart = dialogSource.indexOf(`<section v-if="activeSettingsTab === 'editor'"`);
|
||||
const formatterSectionStart = dialogSource.indexOf(`activeSettingsTab === 'formatter'`, editorSectionStart);
|
||||
const editorSection = dialogSource.slice(editorSectionStart, formatterSectionStart);
|
||||
const editorPreviewStart = dialogSource.indexOf("data-editor-live-preview");
|
||||
const settingsSearchResultsStart = dialogSource.indexOf('v-if="settingsSearchVisible"', editorPreviewStart);
|
||||
const editorPreview = dialogSource.slice(editorPreviewStart, settingsSearchResultsStart);
|
||||
const dataSectionStart = dialogSource.indexOf(`activeSettingsTab === 'data'`);
|
||||
const shortcutsSectionStart = dialogSource.indexOf(`activeSettingsTab === 'shortcuts'`, dataSectionStart);
|
||||
const dataSection = dialogSource.slice(dataSectionStart, shortcutsSectionStart);
|
||||
|
||||
describe("EditorSettingsDialog live preview placement", () => {
|
||||
it("keeps the live preview below search and outside the editor settings scroller", () => {
|
||||
const fontFamily = editorSection.indexOf('t("settings.fontFamily")');
|
||||
const theme = editorSection.indexOf('t("settings.theme")');
|
||||
const fontSize = editorSection.indexOf('t("settings.fontSize")');
|
||||
const previewControls = editorSection.indexOf("data-editor-preview-controls");
|
||||
const executeMode = editorSection.indexOf("executeModeLabel");
|
||||
const searchInput = dialogSource.indexOf("settingsSearchInputContainerRef");
|
||||
const scroller = dialogSource.indexOf('ref="settingsContentScrollRef"');
|
||||
|
||||
expect(editorSectionStart).toBeGreaterThanOrEqual(0);
|
||||
expect(formatterSectionStart).toBeGreaterThan(editorSectionStart);
|
||||
expect(fontFamily).toBeGreaterThanOrEqual(0);
|
||||
expect(theme).toBeGreaterThan(fontFamily);
|
||||
expect(fontSize).toBeGreaterThan(theme);
|
||||
expect(previewControls).toBeGreaterThan(fontSize);
|
||||
expect(executeMode).toBeGreaterThan(previewControls);
|
||||
expect(editorPreviewStart).toBeGreaterThan(searchInput);
|
||||
expect(editorPreviewStart).toBeLessThan(scroller);
|
||||
expect(dialogSource.indexOf("activeSettingsTab === 'editor' && !settingsSearchVisible", searchInput)).toBeLessThan(editorPreviewStart);
|
||||
expect(editorPreview).toContain('ref="previewRef"');
|
||||
expect(editorPreview).toContain("settings-editor-live-preview-surface");
|
||||
expect(dialogSource.match(/ref="previewRef"/g)).toHaveLength(1);
|
||||
expect(editorSection).not.toContain('ref="previewRef"');
|
||||
|
||||
for (const id of ["editor-show-statement-run-buttons", "editor-show-line-numbers", "editor-show-current-statement-frame", "editor-sql-semantic-diagnostics", "editor-show-table-ddl-hover-preview"]) {
|
||||
expect(editorSection.indexOf(`id="${id}"`)).toBeGreaterThan(previewControls);
|
||||
expect(editorSection.indexOf(`id="${id}"`)).toBeLessThan(executeMode);
|
||||
expect(editorSection.match(new RegExp(`id="${id}"`, "g"))).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("unmounts the preview for search results and safely cancels pending initialization or updates", () => {
|
||||
expect(dialogSource).toContain("watch(previewRef, async (el) => {");
|
||||
expect(dialogSource).toContain("if (!el) {");
|
||||
expect(dialogSource).toContain("cleanupPreviewEditor();");
|
||||
expect(dialogSource).toContain("const previewHost = el;");
|
||||
expect(dialogSource).toContain("previewRef.value !== previewHost");
|
||||
expect(dialogSource).toContain("parent: previewHost");
|
||||
expect(dialogSource).toContain("const currentPreviewView = previewView.value;");
|
||||
expect(dialogSource).toContain("previewView.value !== currentPreviewView");
|
||||
expect(dialogSource).toContain("currentPreviewView.dispatch({");
|
||||
expect(dialogSource).toContain(".settings-editor-live-preview-surface {");
|
||||
expect(dialogSource).toContain("max-height: min(16rem, 34vh);");
|
||||
});
|
||||
|
||||
it("places execution behavior before SQL completion with high-priority controls first", () => {
|
||||
const completionSection = editorSection.indexOf("data-editor-sql-completion-settings");
|
||||
const completionTriggerMode = editorSection.indexOf('t("settings.completionTriggerMode")');
|
||||
const selectFirstCompletion = editorSection.indexOf('t("settings.selectFirstCompletionOnOpen")');
|
||||
const executionSection = editorSection.indexOf("data-editor-execution-settings");
|
||||
const executeMode = editorSection.indexOf("executeModeLabel", executionSection);
|
||||
|
||||
expect(executionSection).toBeGreaterThan(editorSection.indexOf("data-editor-preview-controls"));
|
||||
expect(executeMode).toBeGreaterThan(executionSection);
|
||||
expect(completionSection).toBeGreaterThan(executeMode);
|
||||
expect(completionTriggerMode).toBeGreaterThan(completionSection);
|
||||
expect(selectFirstCompletion).toBeGreaterThan(completionTriggerMode);
|
||||
expect(editorSection).not.toContain('t("settings.sqlCompletionSection")');
|
||||
expect(editorSection).toContain('class="settings-item flex items-start justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2" data-editor-completion-trigger-mode');
|
||||
expect(editorSection).toContain('<SelectTrigger class="h-8 w-44 shrink-0">');
|
||||
expect(editorSection).toContain('class="grid gap-4 md:grid-cols-2" data-editor-execution-settings');
|
||||
expect(editorSection).toContain('class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2 md:col-span-2" data-editor-execute-mode');
|
||||
expect(editorSection.match(/:model-value="editCompletionTriggerMode"/g)).toHaveLength(1);
|
||||
expect(editorSection.match(/id="editor-select-first-completion-on-open"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("groups related execution and unsaved SQL controls without removing saved-target behavior", () => {
|
||||
const executionGroup = editorSection.indexOf("data-editor-execution-settings");
|
||||
const executionGroupEnd = editorSection.indexOf("data-editor-sql-completion-settings", executionGroup);
|
||||
const unsavedGroup = editorSection.indexOf("data-editor-unsaved-sql-settings");
|
||||
const unsavedGroupEnd = editorSection.indexOf("generate-sql-include-database-name", unsavedGroup);
|
||||
|
||||
for (const id of ["editor-execute-all-on-blank-line", "editor-show-execution-target-picker", "editor-confirm-dangerous-sql", "editor-continue-on-error"]) {
|
||||
expect(editorSection.indexOf(`id="${id}"`)).toBeGreaterThan(executionGroup);
|
||||
expect(editorSection.indexOf(`id="${id}"`)).toBeLessThan(executionGroupEnd);
|
||||
expect(editorSection.match(new RegExp(`id="${id}"`, "g"))).toHaveLength(1);
|
||||
}
|
||||
for (const id of ["editor-confirm-unsaved-sql-close", "app-close-unsaved-tabs-mode"]) {
|
||||
expect(editorSection.indexOf(`id="${id}"`)).toBeGreaterThan(unsavedGroup);
|
||||
expect(editorSection.indexOf(`id="${id}"`)).toBeLessThan(unsavedGroupEnd);
|
||||
}
|
||||
expect(editorSection).toContain('id="editor-saved-sql-open-target"');
|
||||
expect(editorSection).toContain('class="flex min-w-0 items-start justify-between gap-4"');
|
||||
expect(editorSection).toContain('id="app-close-unsaved-tabs-mode" class="h-8 w-44 shrink-0"');
|
||||
expect(editorSection).not.toContain('id="editor-click-table-navigation-ddl"');
|
||||
expect(editorSection).not.toContain('id="editor-prefill-new-query"');
|
||||
});
|
||||
|
||||
it("describes the execute shortcut below the mode selector in every supported locale", () => {
|
||||
expect(editorSection.indexOf("executeModeDescription")).toBeGreaterThan(editorSection.indexOf("executeModeLabel"));
|
||||
|
||||
for (const locale of ["zh-CN", "zh-TW", "en", "es", "it", "ja", "ko", "pt-BR", "tr", "az"]) {
|
||||
const source = readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8");
|
||||
expect(source, locale).toContain("executeModeDescription:");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a concise description for the selected completion trigger mode", () => {
|
||||
expect(dialogSource).toContain("const completionTriggerModeDescription = computed(() => {");
|
||||
expect(editorSection).toContain("{{ completionTriggerModeDescription }}");
|
||||
expect(editorSection).not.toContain('t("settings.completionTriggerModeDescription")');
|
||||
|
||||
for (const locale of ["zh-CN", "zh-TW", "en", "es", "it", "ja", "ko", "pt-BR", "tr", "az"]) {
|
||||
const source = readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8");
|
||||
for (const key of ["completionTriggerModeManualDescription", "completionTriggerModeRequirePrefixDescription", "completionTriggerModePositionalDescription"]) {
|
||||
expect(source, `${locale}:${key}`).toContain(`${key}:`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("moves table navigation and query prefill controls to Navigation", () => {
|
||||
const navigationSection = dialogSource.slice(dialogSource.indexOf(`activeSettingsTab === 'navigation'`));
|
||||
|
||||
for (const id of ["editor-click-table-navigation-ddl", "editor-prefill-new-query"]) {
|
||||
expect(navigationSection).toContain(`id="${id}"`);
|
||||
expect(dialogSource.match(new RegExp(`id="${id}"`, "g"))).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("groups navigation settings by data tabs and sidebar browsing flow", () => {
|
||||
const navigationSection = dialogSource.slice(dialogSource.indexOf(`activeSettingsTab === 'navigation'`));
|
||||
const adjacentDataTabs = navigationSection.indexOf('id="open-data-tabs-next-to-active"');
|
||||
const objectDisplay = navigationSection.indexOf('t("settings.sidebarObjectDisplay")');
|
||||
const routineOpenMode = navigationSection.indexOf('t("settings.routineSourceOpenMode")');
|
||||
const tableNavigation = navigationSection.indexOf('id="editor-click-table-navigation-ddl"');
|
||||
const browseObjectsOnDatabaseActivation = navigationSection.indexOf('id="sidebar-browse-objects-on-database-activation"');
|
||||
const tableSearch = navigationSection.indexOf('id="sidebar-table-search-enabled"');
|
||||
const activeNodeSelection = navigationSection.indexOf('id="auto-select-active-sidebar-node"');
|
||||
|
||||
expect(browseObjectsOnDatabaseActivation).toBeGreaterThanOrEqual(0);
|
||||
expect(adjacentDataTabs).toBeGreaterThan(browseObjectsOnDatabaseActivation);
|
||||
expect(objectDisplay).toBeGreaterThan(adjacentDataTabs);
|
||||
expect(routineOpenMode).toBeGreaterThan(objectDisplay);
|
||||
expect(tableNavigation).toBeGreaterThan(routineOpenMode);
|
||||
expect(tableSearch).toBeGreaterThan(tableNavigation);
|
||||
expect(activeNodeSelection).toBeGreaterThan(tableSearch);
|
||||
});
|
||||
|
||||
it("puts the data grid filter view first with flat choices and a live preview", () => {
|
||||
const filterView = dataSection.indexOf('data-settings-search-id="data-grid-filter-view"');
|
||||
const dataGridDisplay = dataSection.indexOf('t("settings.dataGridDisplay")');
|
||||
|
||||
expect(dataSectionStart).toBeGreaterThanOrEqual(0);
|
||||
expect(shortcutsSectionStart).toBeGreaterThan(dataSectionStart);
|
||||
expect(filterView).toBeGreaterThanOrEqual(0);
|
||||
expect(filterView).toBeLessThan(dataGridDisplay);
|
||||
expect(dataSection).toContain(`:class="['overflow-hidden rounded-md border bg-muted/20', settingsSearchTargetClass('data-grid-filter-view')]"`);
|
||||
expect(dataSection).toContain("data-data-grid-filter-view-options");
|
||||
expect(dataSection).toContain('v-if="dataGridFilterViewPreviewExpanded" class="pointer-events-none select-none overflow-hidden border-t bg-background" data-data-grid-filter-view-preview');
|
||||
expect(dataSection).toContain("data-data-grid-filter-view-preview-toggle");
|
||||
expect(dataSection).toContain(':aria-expanded="dataGridFilterViewPreviewExpanded"');
|
||||
expect(dataSection).toContain('t("settings.dataGridFilterViewPreview")');
|
||||
expect(dataSection).toContain("dataGridFilterViewPreviewExpanded = !dataGridFilterViewPreviewExpanded");
|
||||
expect(dataSection).toContain("data-data-grid-filter-preview-quick");
|
||||
expect(dataSection).toContain("data-data-grid-filter-preview-conditions");
|
||||
expect(dataSection).toContain("data-data-grid-filter-preview-text");
|
||||
expect(dataSection.match(/text-muted-foreground\/70">\{\{ t\("grid.applyFilter"\) \}\}/g)).toHaveLength(2);
|
||||
expect(dataSection).not.toContain('rounded bg-primary px-2 py-1 text-primary-foreground">{{ t("grid.applyFilter") }}');
|
||||
expect(dataSection.match(/editDataGridFilterEditorView = '(quick|conditions|text)'/g)).toHaveLength(3);
|
||||
expect(dataSection).not.toContain('<SelectTrigger id="data-grid-filter-view"');
|
||||
|
||||
for (const locale of ["zh-CN", "zh-TW", "en", "es", "it", "ja", "ko", "pt-BR", "tr", "az"]) {
|
||||
const source = readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8");
|
||||
for (const key of ["dataGridFilterViewPreview", "dataGridFilterViewPreviewExpand", "dataGridFilterViewPreviewCollapse"]) {
|
||||
expect(source, `${locale}:${key}`).toContain(`${key}:`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("updates preview line-number visibility from the unsaved editor draft", () => {
|
||||
expect(dialogSource).toContain("showLineNumbers: editShowLineNumbers.value");
|
||||
expect(dialogSource).toContain('import { buildQueryEditorLineNumbersExtension } from "@/lib/editor/queryEditorLineNumbers";');
|
||||
expect(dialogSource).toContain('let previewLineNumbersComp: import("@codemirror/state").Compartment | null = null;');
|
||||
expect(dialogSource).toContain('const previewBasicSetup = (basicSetup as readonly import("@codemirror/state").Extension[]).slice(2);');
|
||||
expect(dialogSource).toContain("previewLineNumbersComp.of(buildPreviewLineNumbersExtension(ss.showLineNumbers))");
|
||||
expect(dialogSource).toContain("currentPreviewLineNumbersComp.reconfigure(buildPreviewLineNumbersExtension(ss.showLineNumbers))");
|
||||
expect(dialogSource).toContain("return buildQueryEditorLineNumbersExtension(previewLineNumbersFactory, enabled");
|
||||
});
|
||||
|
||||
it("uses the unsaved font draft for the preview CSS variables", () => {
|
||||
expect(dialogSource).toContain("EDITOR_FONT_FAMILY_CSS_VAR");
|
||||
expect(dialogSource).toContain("EDITOR_FONT_SIZE_CSS_VAR");
|
||||
expect(dialogSource).toContain("const previewFontStyle = computed<Record<string, string>>");
|
||||
expect(dialogSource).toContain("[EDITOR_FONT_FAMILY_CSS_VAR]: editFontFamily.value");
|
||||
expect(dialogSource).toContain("[EDITOR_FONT_SIZE_CSS_VAR]: `${editFontSize.value}px`");
|
||||
expect(dialogSource).toContain(":style=\"{ minWidth: '100%', ...previewFontStyle }\"");
|
||||
expect(dialogSource).toContain("watch([editFontFamily, editFontSize], () => {");
|
||||
expect(dialogSource).toContain("previewView.value?.requestMeasure();");
|
||||
});
|
||||
|
||||
it("creates the preview editor as read-only so demo edits cannot desync the Apply buttons", () => {
|
||||
expect(dialogSource).toContain("EditorState.readOnly.of(true)");
|
||||
expect(dialogSource).toContain("EditorView.editable.of(false)");
|
||||
expect(dialogSource).toContain('EditorView.contentAttributes.of({ tabindex: "0" })');
|
||||
});
|
||||
|
||||
it("explains the intentional syntax-error demo below the preview when diagnostics are enabled", () => {
|
||||
const preview = editorPreview.indexOf('ref="previewRef"');
|
||||
expect(editorPreview).toContain('v-if="editSqlSemanticDiagnosticsEnabled"');
|
||||
expect(editorPreview.indexOf('t("settings.previewSyntaxErrorHint")')).toBeGreaterThan(preview);
|
||||
});
|
||||
|
||||
it.each(["en", "zh-CN", "zh-TW", "es", "pt-BR", "it", "ja", "ko", "tr", "az"])("locale %s translates settings.previewSyntaxErrorHint", (locale) => {
|
||||
const localeSource = readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8");
|
||||
expect(localeSource).toMatch(/previewSyntaxErrorHint: ".+"/);
|
||||
});
|
||||
});
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
// Regression found while verifying issue #1236 (Redis sidebar database list
|
||||
// limit): updateEditorSettings() silently clamps an out-of-range
|
||||
// redisDatabaseDisplayLimit to [MIN, MAX], but persistSettings() only
|
||||
// re-synced the dirty-check baseline (editEditorSettingsBase), not the input's
|
||||
// own v-model ref. Typing a below-minimum value and clicking Apply left the
|
||||
// draft (the unclamped value) permanently out of sync with the saved
|
||||
// (clamped) value, so the dialog kept reporting unsaved changes forever after
|
||||
// a successful apply.
|
||||
describe("EditorSettingsDialog Redis database display limit clamp resync", () => {
|
||||
it("reflects the clamped value back into the input after a successful apply", () => {
|
||||
const block = dialogSource.slice(dialogSource.indexOf("async function persistSettings()"), dialogSource.indexOf("function applySettingsErrorToast"));
|
||||
expect(block).toContain("editEditorSettingsBase.value = editorSettingsDraftFromSettings(settingsStore.editorSettings);");
|
||||
expect(block).toContain("editRedisDatabaseDisplayLimit.value = settingsStore.editorSettings.redisDatabaseDisplayLimit;");
|
||||
// The resync must happen after the clamp-and-persist step, inside the same
|
||||
// "there is a pending editor-settings patch" branch, not unconditionally.
|
||||
const clampResyncIndex = block.indexOf("editRedisDatabaseDisplayLimit.value = settingsStore.editorSettings.redisDatabaseDisplayLimit;");
|
||||
const persistIndex = block.indexOf("await settingsStore.persistEditorSettings();");
|
||||
expect(clampResyncIndex).toBeGreaterThan(persistIndex);
|
||||
});
|
||||
});
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
// Regression for https://github.com/t8y2/dbx/issues/9881: the shortcut draft was
|
||||
// written straight from the captured key event, while persistence re-normalizes it
|
||||
// and swaps "another platform's default" literals for the local platform default.
|
||||
// The draft then never equaled the stored value again, so `hasChanges()` stayed true
|
||||
// forever: "Apply" looked like it did nothing and "Apply and Close" always asked
|
||||
// about unsaved changes — the shortcut (and every other pending edit) could never be
|
||||
// saved. The capture path must therefore write the same value the store will keep.
|
||||
describe("EditorSettingsDialog shortcut capture normalization", () => {
|
||||
it("round-trips a captured combination through the persistence normalizer", () => {
|
||||
expect(dialogSource).toContain("resolveCapturedShortcutEdit,");
|
||||
expect(dialogSource).toContain("const captured = resolveCapturedShortcutEdit(actionId, shortcut, editShortcuts.value);");
|
||||
});
|
||||
|
||||
it("refuses combinations the store would rewrite, keeping the previous shortcut", () => {
|
||||
expect(dialogSource).toContain('toast(t("settings.shortcutPlatformDefault"), 3000);');
|
||||
|
||||
const guardStart = dialogSource.indexOf("if (captured.rejectedByPlatformDefault) {");
|
||||
const guardEnd = dialogSource.indexOf("if (captured.changed) {", guardStart);
|
||||
|
||||
expect(guardStart).toBeGreaterThan(-1);
|
||||
expect(guardEnd).toBeGreaterThan(guardStart);
|
||||
// 拒绝分支必须先结束编辑、且不得写入草稿(保留原值 → 草稿仍是落盘值的不动点)
|
||||
const guardBody = dialogSource.slice(guardStart, guardEnd);
|
||||
expect(guardBody).toContain("editingShortcutId.value = null;");
|
||||
expect(guardBody).toContain("return;");
|
||||
expect(guardBody).not.toContain("editShortcuts.value =");
|
||||
});
|
||||
|
||||
it("adopts the normalized result so the draft can never diverge from the stored value", () => {
|
||||
expect(dialogSource).toContain("editShortcuts.value = captured.shortcuts;");
|
||||
});
|
||||
|
||||
it("re-syncs the shortcut draft from the store after a successful apply", () => {
|
||||
const bodyStart = dialogSource.indexOf("async function persistSettings() {");
|
||||
const bodyEnd = dialogSource.indexOf("function applySettingsErrorToast(", bodyStart);
|
||||
|
||||
expect(bodyStart).toBeGreaterThan(-1);
|
||||
expect(bodyEnd).toBeGreaterThan(bodyStart);
|
||||
expect(dialogSource.slice(bodyStart, bodyEnd)).toContain("editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);");
|
||||
});
|
||||
});
|
||||
Vendored
-74
@@ -1,74 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Source-assertion regression for #9144: the shortcut-capture input width must
|
||||
// come from measuring a hidden mirror span (same box + typography as the real
|
||||
// inputs), not from `label.length + 2em` char-count arithmetic — CJK fallback
|
||||
// fonts can advance wider than 1em and clip the last placeholder glyph. The
|
||||
// dialog cannot be mounted (a 10k-line SFC whose imports pull the whole
|
||||
// editor + stores), so this follows the same `?raw` pattern as
|
||||
// EditorSettingsDialog.mcpQueryTimeout.integration.spec.ts.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import dialogSource from "../EditorSettingsDialog.vue?raw";
|
||||
|
||||
function templateOnly(): string {
|
||||
// The root <template> block, not the first "</template>" — this SFC is full
|
||||
// of nested slot <template> elements, so the first closing tag (and the
|
||||
// naive slice up to it) lands inside the template and misses most of it.
|
||||
return dialogSource.slice(dialogSource.search(/^<template>$/m), dialogSource.lastIndexOf("</template>"));
|
||||
}
|
||||
|
||||
describe("EditorSettingsDialog shortcut capture input width (#9144)", () => {
|
||||
it("mounts exactly one hidden mirror span carrying the shared typography/box classes", () => {
|
||||
const template = templateOnly();
|
||||
|
||||
// The ref token appears exactly once in the template: the mirror span.
|
||||
// (The two script occurrences are the ref declaration and the
|
||||
// useMeasuredWidth call.)
|
||||
expect(template.match(/shortcutPlaceholderMirrorRef/g)).toHaveLength(1);
|
||||
expect(dialogSource.match(/shortcutPlaceholderMirrorRef/g)).toHaveLength(3);
|
||||
|
||||
// Same box + typography classes as the capture inputs so the measured
|
||||
// border-box width includes padding, border, letter-spacing and
|
||||
// fallback-font advances. Asserted against the span's own tag (not the
|
||||
// whole template) — the capture inputs share these classes, so a
|
||||
// template-wide match would pass even with the span stripped.
|
||||
const spanTag = template.match(/<span ref="shortcutPlaceholderMirrorRef"[^>]*>/)?.[0];
|
||||
expect(spanTag).toBeTruthy();
|
||||
for (const cls of ["font-mono", "text-[13px]", "font-semibold", "px-2.5", "border", "whitespace-pre", "invisible"]) {
|
||||
expect(spanTag).toContain(cls);
|
||||
}
|
||||
|
||||
// The label must be interpolated in-band with the span tags, not as
|
||||
// template text between newlines — whitespace-pre would otherwise widen
|
||||
// the measured box.
|
||||
expect(template).toMatch(/<span ref="shortcutPlaceholderMirrorRef"[^>]*>\{\{ shortcutPressShortcutLabel \}\}<\/span>/);
|
||||
});
|
||||
|
||||
it("binds all three capture inputs to the measured width ref", () => {
|
||||
// Two width bindings gate on the row-level editing state (formatter table
|
||||
// + shortcuts table), one on the SQL-shortcut dialog editing state. Each
|
||||
// conditional token appears in exactly one kind of binding.
|
||||
expect(dialogSource.match(/editingShortcutId === definition\.id \? shortcutPressShortcutInputWidth/g)).toHaveLength(2);
|
||||
expect(dialogSource.match(/editingSqlShortcutInputId === 'dialog' \? shortcutPressShortcutInputWidth/g)).toHaveLength(1);
|
||||
|
||||
// Account for every usage of the width ref: 1 fallback initializer,
|
||||
// 1 watcher application, 3 capture-input bindings.
|
||||
expect(dialogSource.match(/shortcutPressShortcutInputWidth/g)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("wires the measured width to the shared mirror ref with the label as revalidate source", () => {
|
||||
expect(dialogSource).toContain("useMeasuredWidth(shortcutPlaceholderMirrorRef, 0, [shortcutPressShortcutLabel])");
|
||||
|
||||
// The applied width adds a ~2px cushion and rounds up so sub-pixel
|
||||
// rounding never under-sizes.
|
||||
expect(dialogSource).toContain("${Math.ceil(width) + 2}px");
|
||||
});
|
||||
|
||||
it("keeps the char-count formula only as the ref fallback initializer", () => {
|
||||
// The legacy `length + 2em` estimate survives solely as the pre-measurement
|
||||
// initializer of the ref.
|
||||
expect(dialogSource).toContain("shortcutPressShortcutInputWidth = ref(`${shortcutPressShortcutLabel.value.length + 2}em`)");
|
||||
|
||||
// No computed re-derives the width from char count anymore.
|
||||
expect(dialogSource).not.toMatch(/computed\(\(\) => `\$\{shortcutPressShortcutLabel\.value\.length \+ 2\}em`\)/);
|
||||
});
|
||||
});
|
||||
+5
-6
@@ -6,12 +6,11 @@ import { createPinia } from "pinia";
|
||||
import { SHORTCUT_DEFINITIONS } from "@/lib/editor/shortcutRegistry";
|
||||
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
|
||||
|
||||
// Reviewer follow-up to the source-contract spec
|
||||
// (EditorSettingsDialog.shortcutResetEditState.spec.ts): prove the actual UI
|
||||
// state transition for #9066 — entering a shortcut row's capture state and
|
||||
// clicking restore-defaults must return the row to its initial pill display,
|
||||
// for both the per-tab footer button and the About tab's full reset (whose
|
||||
// click path is reachable because the capture state survives tab switches).
|
||||
// Proves the actual UI state transition for #9066 — entering a shortcut row's
|
||||
// capture state and clicking restore-defaults must return the row to its
|
||||
// initial pill display, for both the per-tab footer button and the About tab's
|
||||
// full reset (whose click path is reachable because the capture state survives
|
||||
// tab switches).
|
||||
|
||||
vi.mock("vue-i18n", async () => {
|
||||
const { ref } = await import("vue");
|
||||
|
||||
Vendored
-30
@@ -1,30 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
// Regression for https://github.com/t8y2/dbx/issues/9066: clicking a shortcut
|
||||
// row's edit button enters the per-row capture state (editingShortcutId), and
|
||||
// clicking the footer "restore defaults" button afterwards reset the shortcut
|
||||
// values but left the row stuck in the editing state ("press shortcut"
|
||||
// capture input still active). Restoring defaults must also exit the edit
|
||||
// mode so the row returns to its initial pill display. The clear happens at
|
||||
// the top of both reset entrypoints because the edit state can leak across
|
||||
// tab switches (the shortcuts and formatter tables share it).
|
||||
describe("EditorSettingsDialog shortcut restore-defaults exits edit state", () => {
|
||||
it("resetDefaultsForTab clears the in-progress shortcut capture before resetting values", () => {
|
||||
const head = dialogSource.slice(dialogSource.indexOf("function resetDefaultsForTab(tab: SettingsCategory) {"), dialogSource.indexOf('if (tab === "editor") {'));
|
||||
expect(head).toContain("editingShortcutId.value = null;");
|
||||
});
|
||||
|
||||
it("resetAllDefaults clears the in-progress shortcut capture too", () => {
|
||||
const start = dialogSource.indexOf("function resetAllDefaults() {");
|
||||
const head = dialogSource.slice(start, dialogSource.indexOf("editFontFamily.value = DEFAULT_EDITOR_SETTINGS.fontFamily;", start));
|
||||
expect(head).toContain("editingShortcutId.value = null;");
|
||||
});
|
||||
|
||||
it("keeps the footer restore-defaults button wired to resetDefaultsForTab", () => {
|
||||
expect(dialogSource).toContain('@click="resetDefaultsForTab(activeSettingsTab as SettingsCategory)"');
|
||||
expect(dialogSource).toContain('{{ t("settings.resetDefaults") }}');
|
||||
});
|
||||
});
|
||||
+3
-4
@@ -6,13 +6,12 @@ import { createPinia } from "pinia";
|
||||
import { DEFAULT_EDITOR_SETTINGS } from "@/stores/settingsStore";
|
||||
import { SHORTCUT_DEFINITIONS } from "@/lib/editor/shortcutRegistry";
|
||||
|
||||
// Behaviour proof for the scope-grouping / two-tier conflict work, on top of the
|
||||
// source-contract spec (EditorSettingsDialog.shortcutScopeGroups.spec.ts). The
|
||||
// Behaviour proof for the scope-grouping / two-tier conflict work. The
|
||||
// load-bearing claim is the tier split:
|
||||
// · L1 same-scope duplicate -> blocking (red pill, footer reason, Apply gate)
|
||||
// · L2 cross-scope overlap -> informational only (amber pill, never blocking)
|
||||
// The source spec pins the wiring; this one drives the real rendered controls so
|
||||
// a wrong tier can't hide behind a passing string assertion.
|
||||
// This drives the real rendered controls so a wrong tier can't hide behind a
|
||||
// passing string assertion.
|
||||
|
||||
const hoisted = vi.hoisted(() => ({ shortcuts: {} as Record<string, string> }));
|
||||
|
||||
|
||||
Vendored
-61
@@ -1,61 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
// The shortcuts tab groups its 78 actions by scope (global → editor → grid →
|
||||
// search → sidebar) and reports conflicts on two levels:
|
||||
// · same-scope duplicates → blocking (Apply stays disabled, pill turns red)
|
||||
// · cross-scope same chord → informational only (thin amber pill border)
|
||||
// These assertions pin the load-bearing parts of that split: cross-scope
|
||||
// overlaps must never reach the apply gate, the group order must keep matching
|
||||
// the runtime dispatch order, and the L1 red must win over the L2 amber.
|
||||
describe("EditorSettingsDialog shortcut scope grouping", () => {
|
||||
const shortcutsTab = dialogSource.slice(dialogSource.indexOf("activeSettingsTab === 'shortcuts'"), dialogSource.indexOf('data-settings-search-id="sql-shortcuts"'));
|
||||
|
||||
it("groups the shortcuts tab by scope and drops the per-row scope badge", () => {
|
||||
expect(shortcutsTab).toContain('v-for="group in shortcutScopeGroups"');
|
||||
expect(shortcutsTab).toContain(':is="shortcutScopeIcon(group.scope)"');
|
||||
// The scope chip moved into the group header; rows must not repeat it
|
||||
// (the template literal lookup used by the old row badge is gone).
|
||||
expect(dialogSource).not.toContain("shortcutScope${definition.scope");
|
||||
});
|
||||
|
||||
it("keeps the group order equal to the runtime dispatch order", () => {
|
||||
const order = dialogSource.slice(dialogSource.indexOf("const SHORTCUT_SCOPE_ORDER"), dialogSource.indexOf("function shortcutScopeLabelKey"));
|
||||
expect(order).toContain('["global", "editor", "grid", "search", "sidebar"]');
|
||||
});
|
||||
|
||||
it("keeps same-scope conflicts blocking and cross-scope overlaps informational", () => {
|
||||
// L1 comes from findShortcutConflict only…
|
||||
const conflictMap = dialogSource.slice(dialogSource.indexOf("const shortcutConflictMap = computed"), dialogSource.indexOf("const shortcutConflicts = computed(() => Object.keys"));
|
||||
expect(conflictMap).toContain("findShortcutConflict(");
|
||||
expect(conflictMap).not.toContain("findCrossScopeShortcutConflicts");
|
||||
// …and the apply gate must not learn about the cross-scope tier.
|
||||
const blocker = dialogSource.slice(dialogSource.indexOf("const hasBlockingShortcutConflicts = computed"), dialogSource.indexOf("const hasBlockingFormatterConfig"));
|
||||
expect(blocker).toContain("hasShortcutConflicts.value || hasSqlShortcutConflicts.value");
|
||||
expect(blocker).not.toContain("crossScope");
|
||||
});
|
||||
|
||||
it("explains a disabled Apply in the footer now that rows carry no inline message", () => {
|
||||
expect(dialogSource).toContain('v-if="hasBlockingShortcutConflicts"');
|
||||
expect(dialogSource).toContain('t("settings.shortcutConflictBlocksApply"');
|
||||
expect(shortcutsTab).not.toContain('{{ t("settings.shortcutConflict") }}');
|
||||
expect(shortcutsTab).toContain(':text="shortcutConflictHintText(definition)"');
|
||||
});
|
||||
|
||||
it("lets the blocking red win over the informational amber on the pill", () => {
|
||||
// Both states tint the same pill; the cross-scope rule must exclude
|
||||
// aria-invalid rows, otherwise a row that is both would render amber.
|
||||
expect(dialogSource).toContain('.settings-shortcut-row[data-cross-scope="true"] .settings-shortcut-pill:not([aria-invalid="true"])');
|
||||
expect(dialogSource).toContain("border-color: color-mix(in srgb, var(--warning) 45%, transparent);");
|
||||
});
|
||||
|
||||
it("keeps the capture pill's measured-width contract untouched", () => {
|
||||
// The non-editing pill branch is a separate, deliberate formula (see
|
||||
// .trellis/spec/dbx/frontend/keyboard-shortcuts.md, contract 5) — do not
|
||||
// unify it with the measured capture-input width.
|
||||
expect(shortcutsTab).toContain("Math.max(4, formatShortcutPill(editShortcuts[definition.id]).length + 3)}ch");
|
||||
expect(shortcutsTab).toContain("editingShortcutId === definition.id ? shortcutPressShortcutInputWidth");
|
||||
});
|
||||
});
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
// Regression for https://github.com/t8y2/dbx/issues/5905: changing a shortcut
|
||||
// or the sidebar activation mode (and every other draft-gated editor
|
||||
// setting) was silently discarded whenever the dialog closed any way other
|
||||
// than the explicit "Apply"/"Apply and Close" buttons — Escape, clicking
|
||||
// outside, the X button, and every tab's "Close" footer button all bypassed
|
||||
// the draft entirely. Users only noticed after a later app restart (e.g. to
|
||||
// install an update) surfaced the still-default values, making it look like
|
||||
// the update itself had reset their settings.
|
||||
describe("EditorSettingsDialog unsaved-changes close guard", () => {
|
||||
it("imports the shared unsaved-changes decision helper", () => {
|
||||
// Keep in sync with the (oxfmt-formatted) multi-line import block in EditorSettingsDialog.vue.
|
||||
expect(dialogSource).toContain('shouldConfirmEditorSettingsDialogClose,\n type EditorSettingsDraft,\n type EditorSettingsDraftKey,\n} from "@/lib/settings/editorSettingsDraft"');
|
||||
});
|
||||
|
||||
it("routes every close path through the same guard instead of emitting update:open directly", () => {
|
||||
expect(dialogSource).toContain("function requestCloseSettings(nextOpen: boolean) {");
|
||||
expect(dialogSource).toContain("if (shouldConfirmEditorSettingsDialogClose(nextOpen, hasChanges())) {");
|
||||
expect(dialogSource).toContain("showUnsavedSettingsCloseConfirm.value = true;");
|
||||
|
||||
// Escape / outside click / the dialog's own X button
|
||||
expect(dialogSource).toContain("function onSettingsRootOpenChange(value: boolean) {\n if (isSettingsPage.value) return;\n requestCloseSettings(value);\n}");
|
||||
// Every tab footer's "Close" button — must work in both dialog and page
|
||||
// (variant="page") mode, since App.vue relies on closeSettings() emitting
|
||||
// update:open(false) in page mode too (see @update:open on EditorSettingsPage).
|
||||
expect(dialogSource).toContain("function closeSettings() {\n requestCloseSettings(false);\n}");
|
||||
|
||||
// Neither close entrypoint may emit update:open directly anymore - only requestCloseSettings does.
|
||||
const closeSettingsBody = dialogSource.slice(dialogSource.indexOf("function closeSettings()"), dialogSource.indexOf("function cancelUnsavedSettingsClose()"));
|
||||
expect(closeSettingsBody).not.toContain('emit("update:open"');
|
||||
});
|
||||
|
||||
it("lets the user cancel back into the dialog without losing the draft", () => {
|
||||
expect(dialogSource).toContain("function cancelUnsavedSettingsClose() {\n showUnsavedSettingsCloseConfirm.value = false;\n}");
|
||||
});
|
||||
|
||||
it("only actually closes (dropping the draft) once the user explicitly confirms discarding", () => {
|
||||
expect(dialogSource).toContain('function discardUnsavedSettingsAndClose() {\n showUnsavedSettingsCloseConfirm.value = false;\n emit("update:open", false);\n}');
|
||||
});
|
||||
|
||||
it("renders a confirmation dialog wired to the guard state and both actions", () => {
|
||||
expect(dialogSource).toContain('<Dialog :open="showUnsavedSettingsCloseConfirm" @update:open="(value: boolean) => !value && cancelUnsavedSettingsClose()">');
|
||||
expect(dialogSource).toContain('{{ t("settings.unsavedChangesCloseTitle") }}');
|
||||
expect(dialogSource).toContain('{{ t("settings.unsavedChangesCloseMessage") }}');
|
||||
expect(dialogSource).toContain('@click="cancelUnsavedSettingsClose"');
|
||||
expect(dialogSource).toContain('@click="discardUnsavedSettingsAndClose"');
|
||||
});
|
||||
});
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* CTE 接线(hover 溯源、Ctrl+点击跳转、语义模型缓存)分布在独立模块中。
|
||||
* 此处保留结构断言,模块行为另由 useQueryEditorHover 和
|
||||
* useQueryEditorObjectNavigation 的直接测试覆盖。
|
||||
*
|
||||
* 这里锁定回归成本最高的两类契约:分支先后顺序、关键调用参数。算法本身由
|
||||
* cteNavigation.spec.ts / model.spec.ts / sqlNavigation.spec.ts 覆盖。
|
||||
*/
|
||||
const componentSource = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8");
|
||||
const source = [readFileSync(new URL("../useQueryEditorHover.ts", import.meta.url), "utf8"), readFileSync(new URL("../useQueryEditorObjectNavigation.ts", import.meta.url), "utf8")].join("\n");
|
||||
|
||||
/** 截取模块中的函数体,避免跨函数误匹配。 */
|
||||
const metadataSource = readFileSync(new URL("../useQueryEditorCompletionMetadata.ts", import.meta.url), "utf8");
|
||||
|
||||
function functionBody(name: string): string {
|
||||
const functionSource = name === "getEditorSemanticModel" ? metadataSource : source;
|
||||
const start = functionSource.indexOf(`function ${name}(`);
|
||||
expect(start, `QueryEditor.vue 缺少函数 ${name}`).toBeGreaterThanOrEqual(0);
|
||||
const end = functionSource.indexOf("\n }", start);
|
||||
expect(end, `${name} 函数体未闭合`).toBeGreaterThan(start);
|
||||
return functionSource.slice(start, end);
|
||||
}
|
||||
|
||||
function positionOf(fragment: string): number {
|
||||
const index = source.indexOf(fragment);
|
||||
expect(index, `QueryEditor.vue 缺少片段: ${fragment}`).toBeGreaterThanOrEqual(0);
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构片段的归一化:去掉全部空白与"闭合前的尾逗号"。
|
||||
* oxfmt(lint-staged 钩子会执行)会在单行与多行之间重排数组/对象字面量,
|
||||
* 归一化后断言只对代码结构敏感,不会因为纯格式变动而误报。
|
||||
*/
|
||||
function normalizeCode(text: string): string {
|
||||
return text.replace(/\s+/g, "").replace(/,(?=[)\]}])/g, "");
|
||||
}
|
||||
|
||||
const normalizedSource = normalizeCode(source);
|
||||
|
||||
/** 期望片段照常写成可读形式,比较时两侧一起归一化。 */
|
||||
function expectSourceToContain(fragment: string): void {
|
||||
expect(normalizedSource).toContain(normalizeCode(fragment));
|
||||
}
|
||||
|
||||
describe("QueryEditor hover 的语义模型缓存接线", () => {
|
||||
it("连接独立的 hover 和对象导航入口及监听器生命周期", () => {
|
||||
expect(componentSource).toContain("hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos))");
|
||||
expect(componentSource).toContain("mousedown: onEditorMouseDown");
|
||||
expect(componentSource).toContain("objectNavigation.attach();");
|
||||
expect(componentSource).toContain("objectNavigation.dispose();");
|
||||
});
|
||||
it("hover 复用 getEditorSemanticModel,而不是每次重新解析", () => {
|
||||
expect(source).toContain("semanticModel = getEditorSemanticModel(sql, pos, currentView.state);");
|
||||
// 换回逐次解析会让 hover 在每次移动时重复建模型(原实现即为此形态)。
|
||||
expect(source).not.toContain("buildSqlSemanticModel(sql, pos, sqlCompletionDialectOptions())");
|
||||
});
|
||||
|
||||
it("缓存键覆盖文档/编辑器状态/位置/数据库类型/方言,且只在拿到 doc 时写缓存", () => {
|
||||
const body = functionBody("getEditorSemanticModel");
|
||||
|
||||
expect(body).toContain("if (!SEMANTIC_SQL_COMPLETION_ENABLED) return null;");
|
||||
for (const key of ["editorSemanticModelCache?.doc === doc", "editorSemanticModelCache.editorState === editorState", "editorSemanticModelCache.position === position", "editorSemanticModelCache.databaseType === props.databaseType", "editorSemanticModelCache.dialect === dialect"]) {
|
||||
expect(body, `缓存键缺少 ${key}`).toContain(key);
|
||||
}
|
||||
// 没有 doc(手动构造的 state)时不能留下无法复用的缓存项。
|
||||
expect(body).toMatch(/if \(doc\) \{\s*editorSemanticModelCache = \{/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("QueryEditor hover 的 CTE 溯源接线", () => {
|
||||
it("CTE 分支排在通用表/列回退之前", () => {
|
||||
const cteBranch = positionOf("const cteColumn = await resolveCteColumnHoverColumn(semanticModel, name, qualifier);");
|
||||
const genericFallback = positionOf("const hoverTarget = completionMetadataTarget({");
|
||||
|
||||
expect(cteBranch).toBeLessThan(genericFallback);
|
||||
});
|
||||
|
||||
it("命中物理列后按 类型/来源/注释 生成悬浮内容,未命中则回退", () => {
|
||||
expectSourceToContain('createHoverDom(cteColumn.name, cteColumn.dataType || "column", undefined, [cteColumn.schema ? `${cteColumn.schema}.${cteColumn.table}` : cteColumn.table, ...(cteColumn.comment?.trim() ? [cteColumn.comment.trim()] : [])])');
|
||||
// 只有拿到列才提前返回,否则继续走原有表/列逻辑。
|
||||
expect(source).toContain("if (cteColumn) {");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCteColumnHoverColumn 的溯源接线", () => {
|
||||
const body = functionBody("resolveCteColumnHoverColumn");
|
||||
|
||||
it("未命中或不可溯源时返回 null,交由上层回退", () => {
|
||||
expect(body).toContain("const hit = findCteColumnResolution(semanticModel, columnName, qualifier);");
|
||||
expect(body).toContain("if (!hit) return null;");
|
||||
expect(body).toContain("const origins: CteColumnOrigin[] = resolveCteColumnOrigins(semanticModel, hit, columnName);");
|
||||
expect(body).toContain("if (origins.length === 0) return null;");
|
||||
// 元数据抓取异常同样只降级为回退,不能把 hover 打崩。
|
||||
expect(body).toMatch(/catch \{\s*return null;\s*\}/);
|
||||
});
|
||||
|
||||
it("复用共享列缓存并做大小写不敏感匹配,取第一个命中", () => {
|
||||
expect(body).toContain("const reference = referencedTableLikeFromSemanticSource(origin.source);");
|
||||
expect(body).toContain("await ensureColumnsForTable(reference, reference);");
|
||||
expect(body).toContain("const columns = cachedColumnsByTable.get(completionCacheKey(reference));");
|
||||
expect(body).toContain("candidate.name.toLowerCase() === origin.column.toLowerCase()");
|
||||
expect(body).toContain("return matched.find((column) => column != null) ?? null;");
|
||||
});
|
||||
|
||||
it("多星号产生的多个来源并发取元数据,顺序即优先级,第一个命中者胜出", () => {
|
||||
const body = functionBody("resolveCteColumnHoverColumn");
|
||||
|
||||
// 顺序即优先级:谁先取到真实列元数据就用谁(歧义 SQL 的裁决点就在这里)。
|
||||
expect(body).toContain("origins.map(async (origin) => {");
|
||||
expect(body).toContain("?? null;");
|
||||
expect(body.indexOf("origins.map(async (origin) => {")).toBeLessThan(body.indexOf("return matched.find((column) => column != null) ?? null;"));
|
||||
|
||||
// 第二个来源直接复用第一个来源已建好的缓存,不会重复发起元数据查询。
|
||||
expect(body).toContain("cachedColumnsByTable.get(completionCacheKey(reference))");
|
||||
});
|
||||
|
||||
it("把语义来源映射成列缓存所需的表标识(含引号与 schema)", () => {
|
||||
const mapBody = functionBody("referencedTableLikeFromSemanticSource");
|
||||
|
||||
expect(mapBody).toContain("const identifierParts = source.qualifiedName?.parts ?? [];");
|
||||
expect(mapBody).toContain("name: source.name,");
|
||||
expect(mapBody).toContain("nameQuoted: !!identifierParts[identifierParts.length - 1]?.quote,");
|
||||
expect(mapBody).toContain("database: source.metadataTarget?.database,");
|
||||
expect(mapBody).toContain("schema: source.qualifierParts[source.qualifierParts.length - 1],");
|
||||
expect(mapBody).toContain("schemaQuoted: source.qualifierParts.length > 0 ? !!identifierParts[identifierParts.length - 2]?.quote : undefined,");
|
||||
});
|
||||
});
|
||||
|
||||
describe("QueryEditor Ctrl+点击的 CTE 跳转接线", () => {
|
||||
it("CTE 分支排在本地表缓存之前", () => {
|
||||
const cteBranch = positionOf("const cteModel = getEditorSemanticModel(doc, pos, currentView.state);");
|
||||
const localTableCache = positionOf("// 1. Local table lookup with the resolved scope");
|
||||
|
||||
expect(source).toContain("// 0. CTE (WITH ... AS) in-editor navigation");
|
||||
expect(cteBranch).toBeLessThan(localTableCache);
|
||||
});
|
||||
|
||||
it("CTE 引用名命中时跳到定义名 token,并高亮整个 CTE 定义", () => {
|
||||
expect(source).toContain("const referenceHit = findCteReferenceAt(cteModel, pos);");
|
||||
expect(source).toContain("if (referenceHit?.definition.nameSpan) {");
|
||||
expectSourceToContain("{ from: referenceHit.definition.nameSpan.start, to: referenceHit.definition.nameSpan.end }");
|
||||
expectSourceToContain("{ from: referenceHit.definition.sourceSpan.start, to: referenceHit.definition.sourceSpan.end }");
|
||||
});
|
||||
|
||||
it("CTE 列命中时跳到 output 的 jumpSpan 或 body 星号,限定符取倒数列段", () => {
|
||||
expect(source).toContain("const clickQualifier = identity.parts.length >= 2 ? identity.parts[identity.parts.length - 2]?.value : undefined;");
|
||||
expect(source).toContain("const columnHit = findCteColumnResolution(cteModel, identity.name, clickQualifier);");
|
||||
expect(source).toContain("const columnTargetSpan = columnHit?.output?.jumpSpan ?? columnHit?.stars?.[0]?.starSpan;");
|
||||
expect(source).toContain("if (columnHit && columnTargetSpan) {");
|
||||
expectSourceToContain("{ from: columnTargetSpan.start, to: columnTargetSpan.end }");
|
||||
});
|
||||
|
||||
it("解析失败只告警,不阻断后续表/列跳转", () => {
|
||||
expect(source).toContain('console.warn("[DBX] CTE ctrl+click resolution failed:", error);');
|
||||
|
||||
const cteBlock = source.slice(positionOf("const cteModel = getEditorSemanticModel(doc, pos, currentView.state);"), positionOf("// 1. Local table lookup with the resolved scope"));
|
||||
// 两个成功分支各自提前 return;catch 分支只能 warn,否则会吞掉原有的表/列跳转。
|
||||
expect(cteBlock.match(/return;/g) ?? []).toHaveLength(2);
|
||||
expect(cteBlock).not.toMatch(/catch \(error\) \{[\s\S]*?return/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jumpToCteRange 的跳转范围处理", () => {
|
||||
const body = functionBody("jumpToCteRange");
|
||||
|
||||
it("缺少视图或 CodeMirror 模块时直接返回", () => {
|
||||
expect(body).toContain("if (!currentView || !codeMirrorRuntime.editorViewModule || !codeMirrorRuntime.setResultSourceRangeEffect) return;");
|
||||
});
|
||||
|
||||
it("把目标与高亮范围都收敛到文档长度内", () => {
|
||||
expect(body).toContain("const docLength = currentView.state.doc.length;");
|
||||
expect(body).toContain("const targetFrom = Math.max(0, Math.min(target.from, docLength));");
|
||||
expect(body).toContain("const targetTo = Math.max(targetFrom, Math.min(target.to, docLength));");
|
||||
expect(body).toContain("const highlightFrom = Math.max(0, Math.min(highlight.from, docLength));");
|
||||
expect(body).toContain("const highlightTo = Math.max(highlightFrom, Math.min(highlight.to, docLength));");
|
||||
});
|
||||
|
||||
it("空目标不动光标,命中时选中目标 token 并高亮整个定义块", () => {
|
||||
expect(body).toContain("if (targetFrom >= targetTo) return;");
|
||||
expect(normalizeCode(body)).toContain(normalizeCode("selection: { anchor: targetFrom, head: targetTo }"));
|
||||
expectSourceToContain("codeMirrorRuntime.setResultSourceRangeEffect.of({ from: highlightFrom, to: highlightTo })");
|
||||
expectSourceToContain('codeMirrorRuntime.editorViewModule.EditorView.scrollIntoView(targetFrom, { y: "center" })');
|
||||
expect(body).toContain("currentView.focus();");
|
||||
});
|
||||
});
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const editorSource = readFileSync(new URL("../useQueryEditorCompletionKeys.ts", import.meta.url), "utf8");
|
||||
|
||||
function functionSource(name: string) {
|
||||
const start = editorSource.indexOf(`function ${name}(`);
|
||||
const end = editorSource.indexOf("\n }", start);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return editorSource.slice(start, end);
|
||||
}
|
||||
|
||||
// Regression test for #8029: while an IME composition is active, Enter and the
|
||||
// completion-accept shortcut belong to the IME candidate list. The acceptance
|
||||
// handlers gate on the same isEditorComposing helper the completion providers
|
||||
// already use, and the delayed retries must drop their queued acceptance once
|
||||
// a composition starts instead of accepting over (or after) it.
|
||||
describe("QueryEditor IME-safe completion acceptance", () => {
|
||||
it("gates the Enter handler on IME composition before any acceptance", () => {
|
||||
const source = functionSource("handleEnter");
|
||||
const composingGuard = source.indexOf("if (isEditorComposing(view)) return false;");
|
||||
const acceptCall = source.indexOf("codeMirrorAcceptCompletion?.(view)");
|
||||
|
||||
expect(composingGuard).toBeGreaterThanOrEqual(0);
|
||||
expect(acceptCall).toBeGreaterThan(composingGuard);
|
||||
});
|
||||
|
||||
it("gates the Tab completion-accept handler on IME composition", () => {
|
||||
const source = functionSource("acceptCompletionOrNextSnippetField");
|
||||
const composingGuard = source.indexOf("if (isEditorComposing(view)) return false;");
|
||||
const selectionCheck = source.indexOf("view.state.selection.ranges.every");
|
||||
|
||||
expect(composingGuard).toBeGreaterThanOrEqual(0);
|
||||
expect(selectionCheck).toBeGreaterThan(composingGuard);
|
||||
});
|
||||
|
||||
it("drops the pending Tab acceptance retry when an IME composition starts", () => {
|
||||
const source = functionSource("waitForCompletionTab");
|
||||
const retry = source.indexOf("const retry = () => {");
|
||||
const composingGuard = source.indexOf("if (isEditorComposing(view)) return;", retry);
|
||||
|
||||
expect(retry).toBeGreaterThanOrEqual(0);
|
||||
expect(composingGuard).toBeGreaterThan(retry);
|
||||
});
|
||||
|
||||
it("passes an IME guard to the Enter acceptance retry helper", () => {
|
||||
const source = functionSource("handleEnter");
|
||||
expect(source.includes("isComposing: () => isEditorComposing(view)")).toBe(true);
|
||||
});
|
||||
});
|
||||
Vendored
-37
@@ -1,37 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* SQL Server 例程批次的语法诊断接线(#9315)写在诊断 composable 内部,
|
||||
* 无法直接 import,因此沿用仓库既有做法(QueryEditor.cteNavigationWiring.spec.ts)
|
||||
* 对源码做结构断言。规则本身由 sqlServerRoutineSyntaxDiagnostics.test.ts 覆盖,
|
||||
* 批次/视口裁剪由 sqlSemanticDiagnostics.test.ts 覆盖,这里只锁定接线契约。
|
||||
*/
|
||||
const source = readFileSync(new URL("../useQueryEditorDiagnostics.ts", import.meta.url), "utf8");
|
||||
|
||||
function normalizeCode(text: string): string {
|
||||
return text.replace(/\s+/g, "").replace(/,(?=[)\]}])/g, "");
|
||||
}
|
||||
|
||||
const normalizedSource = normalizeCode(source);
|
||||
|
||||
function expectSourceToContain(fragment: string): void {
|
||||
expect(normalizedSource).toContain(normalizeCode(fragment));
|
||||
}
|
||||
|
||||
describe("QueryEditor 的 SQL Server 例程语法诊断接线", () => {
|
||||
it("例程批次被跳过整个语义范围后仍单独取回", () => {
|
||||
// sqlSemanticDiagnosticRangesForViewport 对例程批次返回空,所以必须再取一次。
|
||||
expectSourceToContain('const sqlServerRoutineRanges = props.databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : [];');
|
||||
// 例程批次不能因为普通范围为空就提前 return,否则过程体永远不检查。
|
||||
expectSourceToContain("if (diagnosticRanges.length === 0 && sqlServerRoutineRanges.length === 0) {");
|
||||
});
|
||||
|
||||
it("例程批次只跑 token 规则,不做引用分析", () => {
|
||||
expectSourceToContain("nextDiagnostics.push(...offsetSqlSemanticDiagnostics(buildSqlServerRoutineSyntaxDiagnostics(range.sql, props.databaseType), range, sql));");
|
||||
});
|
||||
|
||||
it("增量刷新时把例程批次一并纳入替换范围,避免旧诊断残留", () => {
|
||||
expectSourceToContain("replaceSemanticDiagnosticsInRanges(nextDiagnostics, [...diagnosticRanges, ...sqlServerRoutineRanges], sql);");
|
||||
});
|
||||
});
|
||||
@@ -1,83 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { computed, createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
|
||||
const source = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
|
||||
|
||||
const menuSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/QueryEditorContextMenu.vue"), "utf8");
|
||||
|
||||
const documentSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
|
||||
const batchSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/useQueryEditorBatchSelection.ts"), "utf8");
|
||||
|
||||
const completionSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/useQueryEditorCompletion.ts"), "utf8");
|
||||
const completionKeysSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/useQueryEditorCompletionKeys.ts"), "utf8");
|
||||
const extensionsSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/queryEditorSqlExtensions.ts"), "utf8");
|
||||
|
||||
describe("QueryEditor context menu lifecycle", () => {
|
||||
it("keeps heavyweight context derivation out of the per-update path", () => {
|
||||
const updateStart = source.indexOf("EditorView.updateListener.of((update) => {");
|
||||
const updateEnd = source.indexOf("initializedRuntime.fontThemeComp.of(", updateStart);
|
||||
const updateSource = source.slice(updateStart, updateEnd);
|
||||
|
||||
expect(updateStart).toBeGreaterThanOrEqual(0);
|
||||
expect(updateEnd).toBeGreaterThan(updateStart);
|
||||
expect(updateSource).toContain("syncEditorSelectionState(update.view);");
|
||||
expect(updateSource).toContain("schedulePreviewContextRefresh(update.view);");
|
||||
expect(updateSource).not.toContain("syncContextMenuState(update.view);");
|
||||
});
|
||||
|
||||
it("does not suppress legitimate external model updates", () => {
|
||||
expect(source).not.toContain("lastEmittedModelValue");
|
||||
expect(documentSource).toContain("if (val !== currentEditorDocText(view.value)) {");
|
||||
});
|
||||
|
||||
it("resynchronizes context state after restoring a tab document", () => {
|
||||
const activateStart = documentSource.indexOf("function activateTabDocument");
|
||||
const activateEnd = documentSource.indexOf("\n watch([() => props.tabId", activateStart);
|
||||
const activateSource = documentSource.slice(activateStart, activateEnd);
|
||||
|
||||
expect(activateStart).toBeGreaterThanOrEqual(0);
|
||||
expect(activateEnd).toBeGreaterThan(activateStart);
|
||||
expect(activateSource).toContain("syncContextMenuState(currentView);");
|
||||
expect(activateSource).toContain('emit("previewChangesAvailable", !!previewContextSql.value);');
|
||||
});
|
||||
|
||||
it("guards deferred preview refresh against stale selection ranges", () => {
|
||||
expect(source).toContain("const expectedSelection = currentView.state.selection.main;");
|
||||
expect(source).toContain("currentSelection.from !== expectedSelection.from");
|
||||
expect(source).toContain("currentSelection.to !== expectedSelection.to");
|
||||
});
|
||||
|
||||
it("resolves menu items after synchronizing the right-click target", () => {
|
||||
const syncStart = source.indexOf("function syncContextMenuStateAtEvent");
|
||||
const syncEnd = source.indexOf("\n}", syncStart);
|
||||
const syncSource = source.slice(syncStart, syncEnd);
|
||||
const syncIndex = source.indexOf("syncContextMenuStateAtEvent(view, e);");
|
||||
const openIndex = source.indexOf("onContextMenu(e);", syncIndex);
|
||||
const getterStart = menuSource.indexOf("function currentContextMenuItems()");
|
||||
const getterEnd = menuSource.indexOf("\n}", getterStart);
|
||||
const getterSource = menuSource.slice(getterStart, getterEnd);
|
||||
|
||||
expect(syncStart).toBeGreaterThanOrEqual(0);
|
||||
expect(syncEnd).toBeGreaterThan(syncStart);
|
||||
expect(syncSource).toContain("if (pos == null)");
|
||||
expect(syncSource).toContain("contextObjectTarget.value = null;");
|
||||
expect(syncIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(openIndex).toBeGreaterThan(syncIndex);
|
||||
expect(getterStart).toBeGreaterThanOrEqual(0);
|
||||
expect(getterEnd).toBeGreaterThan(getterStart);
|
||||
expect(getterSource).toContain("return contextMenuItems.value;");
|
||||
expect(getterSource).not.toContain("nextTick");
|
||||
expect(getterSource).not.toContain("setTimeout");
|
||||
expect(menuSource).toContain(':items="currentContextMenuItems"');
|
||||
expect(source).toContain(':get-state="getContextMenuState"');
|
||||
expect(menuSource).not.toContain('<CustomContextMenu :items="contextMenuItems"');
|
||||
});
|
||||
|
||||
it("uses the target synchronized in the current context-menu event", async () => {
|
||||
const target = ref<string | null>(null);
|
||||
const openedTargets: string[] = [];
|
||||
@@ -142,70 +69,3 @@ describe("QueryEditor context menu lifecycle", () => {
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("QueryEditor batch column selection", () => {
|
||||
it("keeps the confirmation action visible during pinyin filtering and supports keyboard toggling", () => {
|
||||
const bypassFilterStart = completionSource.indexOf("function completionItemsForBypassedFilter");
|
||||
const bypassFilterEnd = completionSource.indexOf("\n }\n\n function localCompletionDatabaseNames", bypassFilterStart);
|
||||
const bypassFilterSource = completionSource.slice(bypassFilterStart, bypassFilterEnd);
|
||||
|
||||
expect(bypassFilterStart).toBeGreaterThanOrEqual(0);
|
||||
expect(bypassFilterEnd).toBeGreaterThan(bypassFilterStart);
|
||||
expect(bypassFilterSource).toContain("if (isBatchColumnSelectionAction(item)) return true;");
|
||||
expect(source).toContain('key: "Space"');
|
||||
expect(source).toContain("run: toggleSelectedBatchColumnSelection");
|
||||
expect(completionKeysSource).toContain("codeMirrorSelectedCompletion?.(view.state)");
|
||||
});
|
||||
|
||||
it("applies checked columns directly through Enter and the completion Tab shortcut", () => {
|
||||
const handleEnterStart = completionKeysSource.indexOf("function handleEnter");
|
||||
const handleEnterEnd = completionKeysSource.indexOf("\n }", handleEnterStart);
|
||||
const tabStart = completionKeysSource.indexOf("function acceptCompletionOrNextSnippetField");
|
||||
const tabEnd = completionKeysSource.indexOf("\n }", tabStart);
|
||||
|
||||
expect(batchSource).toContain("function applySelectedBatchColumnSelection");
|
||||
expect(completionKeysSource.slice(handleEnterStart, handleEnterEnd)).toContain("if (isBatchColumnSelectionCompletionActive(codeMirrorRuntime.codeMirrorCompletionStatus?.(view.state) ?? null) && applySelectedBatchColumnSelection(view)) return true;");
|
||||
expect(completionKeysSource.slice(handleEnterStart, handleEnterEnd)).toContain("if (codeMirrorRuntime.codeMirrorAcceptCompletion?.(view)) return true;");
|
||||
expect(completionKeysSource.slice(tabStart, tabEnd)).toContain("if (isBatchColumnSelectionCompletionActive(completionStatus) && applySelectedBatchColumnSelection(view)) return true;");
|
||||
expect(extensionsSource).toContain("defaultKeymap: false");
|
||||
expect(source).toContain('{ key: "ArrowDown", run: (view) => moveCompletion(view, true) }');
|
||||
expect(source).toContain('{ key: "ArrowUp", run: (view) => moveCompletion(view, false) }');
|
||||
});
|
||||
|
||||
it("marks the insertion action so the completion menu can keep it sticky", () => {
|
||||
expect(completionSource).toContain("dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey, ...(item.batchColumnSelectionToggleAll ? { toggleAll: true as const } : {}) }");
|
||||
expect(extensionsSource).toContain('optionClass: (completion) => ((completion as QueryCompletionOption).dbxBatchColumnSelectionAction ? "cm-batch-column-selection-action" : "")');
|
||||
});
|
||||
|
||||
it("updates the insertion count while a mouse selection is still in progress", () => {
|
||||
const dragUpdateStart = batchSource.indexOf("function updateBatchColumnSelectionAtPoint");
|
||||
const dragUpdateEnd = batchSource.indexOf("\n }\n\n const BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX", dragUpdateStart);
|
||||
|
||||
expect(batchSource).toContain("function updateBatchColumnSelectionActionLabel");
|
||||
expect(batchSource.slice(dragUpdateStart, dragUpdateEnd)).toContain("updateBatchColumnSelectionActionLabel(state.view, state.sessionKey);");
|
||||
expect(batchSource).toContain("const batchColumnSelectionTooltipParents = new WeakMap<EditorViewType, HTMLElement>();");
|
||||
expect(batchSource).toContain("batchColumnSelectionTooltipParents.set(currentView, tooltipParent);");
|
||||
expect(batchSource).toContain("const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, { sessionKey: string; toggleAll: boolean }>();");
|
||||
expect(batchSource).toContain("!markerState || markerState.sessionKey !== sessionKey");
|
||||
expect(batchSource).toContain("renderBatchColumnSelectionActionMarker");
|
||||
});
|
||||
|
||||
it("cancels stale scroll restoration before a new drag can begin", () => {
|
||||
const refreshStart = batchSource.indexOf("function scheduleBatchColumnSelectionRefresh");
|
||||
const refreshEnd = batchSource.indexOf("\n }\n\n function finishBatchColumnSelectionDrag", refreshStart);
|
||||
const dragStart = batchSource.indexOf("function startBatchColumnSelectionDrag");
|
||||
const dragEnd = batchSource.indexOf("\n }\n\n function renderBatchColumnSelectionCheckbox", dragStart);
|
||||
|
||||
expect(batchSource).toContain("let batchColumnSelectionRefreshCleanup: (() => void) | null = null;");
|
||||
expect(batchSource.slice(refreshStart, refreshEnd)).toContain("cancelBatchColumnSelectionRefresh();");
|
||||
expect(batchSource.slice(dragStart, dragEnd)).toContain("cancelBatchColumnSelectionRefresh();");
|
||||
expect(batchSource).toContain("if (restoreStartTimer) window.clearTimeout(restoreStartTimer);");
|
||||
});
|
||||
|
||||
it("only expands rendering for batch field selection", () => {
|
||||
expect(batchSource).toContain("function setBatchColumnSelectionExpandedRendering");
|
||||
expect(batchSource).toContain("setBatchColumnSelectionExpandedRendering(true);");
|
||||
expect(batchSource).toContain("setBatchColumnSelectionExpandedRendering(false);");
|
||||
expect(extensionsSource).toContain("maxRenderedOptions: batchSelection.expandedRendering ? Number.MAX_SAFE_INTEGER : 100,");
|
||||
});
|
||||
});
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const queryEditorSource = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("QueryEditor goto-line wiring", () => {
|
||||
it("binds the platform goto-line shortcut next to find, before formatSql", () => {
|
||||
const gotoLineIndex = queryEditorSource.indexOf("...binding(shortcuts.gotoLine, openGotoLine),");
|
||||
const findIndex = queryEditorSource.indexOf("...binding(shortcuts.find, openSearch),");
|
||||
const formatIndex = queryEditorSource.indexOf("...binding(shortcuts.formatSql,");
|
||||
|
||||
expect(findIndex).toBeGreaterThan(-1);
|
||||
expect(gotoLineIndex).toBeGreaterThan(findIndex);
|
||||
// 非 mac 上 Ctrl+G 同时是 CodeMirror 内置的 find-next,运行时的 Prec.high
|
||||
// keymap 先匹配先执行,因此跳转绑定必须注册在搜索键位之前。
|
||||
expect(formatIndex).toBeGreaterThan(gotoLineIndex);
|
||||
});
|
||||
|
||||
it("routes the panel toggles through the exposed handles", () => {
|
||||
expect(queryEditorSource).toContain("function openGotoLine(): boolean {\n return gotoLinePanelRef.value?.openGotoLine() ?? false;\n}");
|
||||
expect(queryEditorSource).toContain('<EditorGotoLinePanel ref="gotoLinePanelRef" :view="view" @open="searchPanelRef?.closeSearch()" />');
|
||||
expect(queryEditorSource).toContain('<EditorSearchPanel ref="searchPanelRef" :view="view" @open="gotoLinePanelRef?.closeGotoLine()" />');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dashboardSource = readFileSync(new URL("../EtcdDashboard.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("EtcdDashboard refresh concurrency", () => {
|
||||
it("skips overlapping refreshes for the same connection", () => {
|
||||
expect(dashboardSource).toContain("if (loading.value && loadingConnectionId === connectionId) return");
|
||||
});
|
||||
|
||||
it("discards responses from an obsolete load generation", () => {
|
||||
expect(dashboardSource).toContain("const generation = ++loadGeneration");
|
||||
expect(dashboardSource).toContain("if (generation !== loadGeneration || props.connectionId !== connectionId) return");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readCascadeCss } from "../../../styles/__tests__/cascadeCss";
|
||||
|
||||
const nodeTreeSource = readFileSync(new URL("../ExplainPlanNodeTree.vue", import.meta.url), "utf8");
|
||||
const globalStyles = readCascadeCss();
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
@@ -61,43 +59,6 @@ function blend(foreground: Rgb, background: Rgb, opacity: number): Rgb {
|
||||
}
|
||||
|
||||
describe("ExplainPlanNodeTree interactions", () => {
|
||||
it("keeps a concise preview of the most useful details in the tree row", () => {
|
||||
expect(nodeTreeSource).toContain('const detailPreviewEntries = computed(() => detailEntries.value.filter((detail) => detail.label !== "Actual Rows").slice(0, 2))');
|
||||
expect(nodeTreeSource).toContain('<div v-if="detailPreviewEntries.length"');
|
||||
expect(nodeTreeSource).toContain('v-for="(detail, index) in detailPreviewEntries"');
|
||||
expect(nodeTreeSource).toContain('class="min-w-0 flex-1 truncate"');
|
||||
expect(nodeTreeSource).not.toContain("text-muted-foreground/40");
|
||||
});
|
||||
|
||||
it("shows long details in a click-triggered, selectable popover", () => {
|
||||
expect(nodeTreeSource).toContain('import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"');
|
||||
expect(nodeTreeSource).toContain('<Popover v-if="detailEntries.length">');
|
||||
expect(nodeTreeSource).toContain("<PopoverTrigger as-child>");
|
||||
expect(nodeTreeSource).toContain('<PopoverContent align="end"');
|
||||
expect(nodeTreeSource).toContain('data-native-clipboard class="max-h-');
|
||||
expect(nodeTreeSource).toContain("select-text whitespace-pre-wrap break-words");
|
||||
expect(nodeTreeSource).not.toContain("node.details.join");
|
||||
expect(nodeTreeSource).not.toContain("{{ detailEntries.length }}");
|
||||
expect(nodeTreeSource.match(/\{\{ t\("explain\.details"\) \}\}/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps detail fields distinct and tree disclosure on its own button", () => {
|
||||
expect(nodeTreeSource).toContain('const separatorIndex = detail.indexOf(":")');
|
||||
expect(nodeTreeSource).toContain('v-if="detail.label"');
|
||||
expect(nodeTreeSource).toContain('<Button v-if="hasChildren"');
|
||||
expect(nodeTreeSource).toContain(':aria-expanded="!collapsed"');
|
||||
expect(nodeTreeSource).toContain('@click="toggleCollapsed"');
|
||||
expect(nodeTreeSource).not.toContain("cursor-pointer");
|
||||
expect(nodeTreeSource).not.toContain('@click="toggle"');
|
||||
});
|
||||
|
||||
it("uses semantic foreground tokens instead of fixed status hues", () => {
|
||||
expect(nodeTreeSource).toContain("text-foreground/80");
|
||||
expect(nodeTreeSource).toContain("text-popover-foreground");
|
||||
expect(nodeTreeSource).not.toMatch(/text-(?:blue|emerald|amber|green)-/);
|
||||
expect(nodeTreeSource).not.toMatch(/border-(?:blue|emerald|amber|green)-/);
|
||||
});
|
||||
|
||||
it("keeps foreground-based tree metadata readable in every application palette", () => {
|
||||
expect(themeColors).toHaveLength(26);
|
||||
|
||||
|
||||
Vendored
-36
@@ -1,36 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../DatabaseExportDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionSource(name: string, nextName: string) {
|
||||
const start = dialogSource.indexOf(`async function ${name}(`);
|
||||
const end = dialogSource.indexOf(`async function ${nextName}(`, start);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return dialogSource.slice(start, end);
|
||||
}
|
||||
|
||||
describe("DatabaseExportDialog destination identity", () => {
|
||||
it("records a manually selected file destination before starting a single-database export", () => {
|
||||
const source = functionSource("startExport", "startAllDatabasesExport");
|
||||
const selectedPath = source.indexOf("const path = await save(");
|
||||
const recordedDestination = source.indexOf("recordDatabaseExportDestination(await dirname(path))");
|
||||
const exportStarted = source.indexOf("isExporting.value = true");
|
||||
|
||||
expect(selectedPath).toBeGreaterThanOrEqual(0);
|
||||
expect(recordedDestination).toBeGreaterThan(selectedPath);
|
||||
expect(exportStarted).toBeGreaterThan(recordedDestination);
|
||||
});
|
||||
|
||||
it("records a manually selected directory before starting an all-databases export", () => {
|
||||
const source = functionSource("startAllDatabasesExport", "cancelExport");
|
||||
const selectedPath = source.indexOf("const path = await openDialog(");
|
||||
const recordedDestination = source.indexOf("recordDatabaseExportDestination(path)");
|
||||
const exportStarted = source.indexOf("isExporting.value = true");
|
||||
|
||||
expect(selectedPath).toBeGreaterThanOrEqual(0);
|
||||
expect(recordedDestination).toBeGreaterThan(selectedPath);
|
||||
expect(exportStarted).toBeGreaterThan(recordedDestination);
|
||||
});
|
||||
});
|
||||
-14
@@ -1,7 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { createApp, defineComponent, h, markRaw, nextTick, type App, type PropType } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -493,16 +491,4 @@ describe("DataGrid multi-row paste from a blank cell", () => {
|
||||
|
||||
expect(updates).toEqual([{ $set: { status: "{plain text" } }, { $set: { status: "{plain text" } }]);
|
||||
});
|
||||
|
||||
it("routes DOM and canvas cell gestures through the same selection preparation", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGrid.vue"), "utf8");
|
||||
const domGesture = source.slice(source.indexOf('@mousedown="\n prepareDataCellMouseDown'), source.indexOf('@mouseenter="onCellMouseenter'));
|
||||
const canvasGesture = source.slice(source.indexOf("function onCanvasMouseDown"), source.indexOf("function onCanvasContext"));
|
||||
|
||||
expect(domGesture).toContain("prepareDataCellMouseDown(item, col.actualColIdx);");
|
||||
expect(domGesture).toContain("handleDataCellMousedown(item.displayIndex, col.visibleColIdx, item.id, $event);");
|
||||
expect(canvasGesture).toContain("prepareDataCellMouseDown(item, actualColIdx)");
|
||||
expect(canvasGesture).toContain("handleDataCellMousedown(item.displayIndex, hit.visibleColIdx, item.id, event)");
|
||||
expect(source).toContain("columnIndexes: visibleColumnIndexes.value.slice(range.startCol)");
|
||||
});
|
||||
});
|
||||
|
||||
-62
@@ -1,62 +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 cell detail selection", () => {
|
||||
it("selects the first available cell when opening Mongo JSON preview without a selection", () => {
|
||||
const togglePreview = dataGridSource.match(/function toggleMongoJsonPreview[\s\S]*?\n\}/)?.[0];
|
||||
expect(togglePreview).toContain("if (showMongoJsonPreview.value)");
|
||||
expect(togglePreview).toContain("!currentSelectedCellPosition() && displayItems.value.length > 0 && visibleColumnIndexes.value.length > 0");
|
||||
expect(togglePreview).toContain("selectSingleCell(0, 0)");
|
||||
});
|
||||
|
||||
it("keeps Canvas hover state while the renderer swaps drawing surfaces", () => {
|
||||
expect(dataGridSource).toContain("function isCanvasGridInteractionTarget(target: Node): boolean");
|
||||
expect(dataGridSource).toContain("canvasOverlayRef.value?.contains(target) === true");
|
||||
expect(dataGridSource).toContain("canvasRef.value?.contains(target) === true");
|
||||
expect(dataGridSource).toContain("canvasBackRef.value?.contains(target) === true");
|
||||
|
||||
const canvasLeave = dataGridSource.match(/function onCanvasMouseLeave[\s\S]*?\n\}/)?.[0];
|
||||
const detailLeave = dataGridSource.match(/function clearCanvasDetailHover[\s\S]*?\n\}/)?.[0];
|
||||
expect(canvasLeave).toContain("isCanvasGridInteractionTarget(relatedTarget)");
|
||||
expect(detailLeave).toContain("isCanvasGridInteractionTarget(relatedTarget)");
|
||||
});
|
||||
|
||||
it("gates only the three hover Info buttons with the visibility setting", () => {
|
||||
expect(dataGridSource).toContain("const cellDetailButtonEnabled = computed(() => settingsStore.editorSettings.dataGridCellDetailButtonVisible);");
|
||||
expect(dataGridSource.match(/v-if="cellDetailButtonEnabled"/g)).toHaveLength(3);
|
||||
expect(dataGridSource.match(/v-if="cellDetailButtonVisible\(/g)).toHaveLength(2);
|
||||
|
||||
const hoverVisibility = dataGridSource.match(/function cellDetailButtonVisible[\s\S]*?\n\}/)?.[0];
|
||||
expect(hoverVisibility).toBeDefined();
|
||||
expect(hoverVisibility).not.toContain("dataGridCellDetailButtonVisible");
|
||||
});
|
||||
|
||||
it("removes the Canvas action overlay and reservation only when no action remains", () => {
|
||||
expect(dataGridSource).toContain("if (!cellDetailButtonEnabled.value && !canQuickDownload && !foreignKey && !externalUrl) return null;");
|
||||
expect(dataGridSource).toContain("canvasDataGridActionOverlayWidth(canQuickDownload, !!foreignKey, cellDetailButtonEnabled.value, !!externalUrl)");
|
||||
expect(dataGridSource).toContain("canvasDataGridActionOverlayWidth(cell.canQuickDownload, !!cell.foreignKey, cellDetailButtonEnabled.value, !!cell.externalUrl)");
|
||||
expect(dataGridSource).toContain("canvasDataGridActionReservedWidth(cell.canQuickDownload, !!cell.foreignKey, cellDetailButtonEnabled.value, !!cell.externalUrl)");
|
||||
expect(dataGridSource).toMatch(/watch\([\s\S]*?cellDetailButtonEnabled,[\s\S]*?scheduleCanvasDraw/);
|
||||
});
|
||||
|
||||
it("keeps context-menu details and refresh restoration independent of the hover setting", () => {
|
||||
expect(dataGridSource).toContain("cellDetails: openContextCellDetailDialog");
|
||||
expect(dataGridSource).toContain("if (cellDialog) openCellDetailDialog(cellDialog.rowIndex, cellDialog.col);");
|
||||
});
|
||||
|
||||
it("resynchronizes the open detail after a mouse selection gesture finishes", () => {
|
||||
expect(dataGridSource).toContain("watch([selectedRange, showCellDetail, isEditingDetail, isSelectingCells]");
|
||||
expect(dataGridSource).toContain("if (isSelectingCells.value) return;");
|
||||
expect(dataGridSource).toMatch(/detailCell\.value = target;\s+hydrateCellDetailTarget\(target\);/);
|
||||
});
|
||||
|
||||
it("hydrates bounded large-value previews for every cell detail target", () => {
|
||||
expect(largeValueSource).toContain("function isLargeValuePreview");
|
||||
expect(dataGridSource).toContain("function hydrateLargeValueCell");
|
||||
expect(dataGridSource).toMatch(/showCellDetails[\s\S]*?hydrateCellDetailTarget\(detailCell\.value\)/);
|
||||
expect(dataGridSource).toMatch(/openCellDetailDialog[\s\S]*?hydrateCellDetailTarget\(cellDetailDialogTarget\.value\)/);
|
||||
});
|
||||
});
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("DataGrid synchronized column order selection", () => {
|
||||
it("reconciles the active selection when another grid changes the table order", () => {
|
||||
expect(dataGridSource).toContain("applyColumnOrderChange(() => onTableDataGridColumnOrderChanged(event));");
|
||||
expect(dataGridSource).toContain("window.addEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged);");
|
||||
expect(dataGridSource).toContain("window.removeEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged);");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import DataGridConditionEditor from "@/components/grid/DataGridConditionEditor.vue";
|
||||
import type { DataGridConditionHistoryKind } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
@@ -258,97 +256,6 @@ describe("DataGridConditionEditor quote completion", () => {
|
||||
expect(document.querySelector('[role="listbox"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the wrapped input caret aligned with its syntax highlight layer", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const expandedInputCss = source.match(/\.data-grid-topbar-condition-input--expanded\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
const expandedHighlightCss = source.match(/\.data-grid-condition-highlight--expanded\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
const prefixPadding = "calc(var(--data-grid-condition-prefix-indent) + 0.125rem)";
|
||||
|
||||
expect(expandedInputCss).toContain("padding:");
|
||||
expect(expandedInputCss).toContain(prefixPadding);
|
||||
expect(expandedHighlightCss).toContain(prefixPadding);
|
||||
expect(expandedInputCss).not.toContain("text-indent:");
|
||||
expect(expandedHighlightCss).not.toContain("text-indent:");
|
||||
expect(expandedInputCss).toContain("overflow-wrap: anywhere");
|
||||
expect(source).toContain("white-space:pre-wrap;overflow-wrap:anywhere;");
|
||||
expect(source).not.toContain("textIndent: rect.prefix");
|
||||
expect(source).toContain("paddingLeft: rect.prefix + 2");
|
||||
expect(source).toContain("paddingRight: rect.suffix + 8");
|
||||
expect(source).toContain("width: Math.max(1, rect.width - 8)");
|
||||
expect(source).toContain("function fitExpandedHeightToOverlay()");
|
||||
expect(source).toContain("expandedHeight.value + overflow");
|
||||
});
|
||||
|
||||
it("keeps the expanded condition label readable over wrapped content", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const floatingControls = source.match(/data-grid-topbar-condition-floating-controls[^"]*/)?.[0];
|
||||
const floatingLabelCss = source.match(/\.data-grid-topbar-condition-label--floating\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
const compactFloatingLabelCss = source.match(/\.data-grid-topbar-condition-label--floating\.data-grid-topbar-condition-label--compact\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
|
||||
expect(floatingControls).toContain("z-[2]");
|
||||
expect(source).toContain("data-grid-topbar-condition-label--floating");
|
||||
expect(source).toContain("label.scrollWidth + 4");
|
||||
expect(floatingLabelCss).toContain("text-shadow:");
|
||||
expect(floatingLabelCss).not.toContain("padding-right:");
|
||||
expect(floatingLabelCss).not.toContain("box-shadow:");
|
||||
expect(compactFloatingLabelCss).toContain("max-width: 5rem");
|
||||
expect(compactFloatingLabelCss).toContain("opacity: 1");
|
||||
});
|
||||
|
||||
it("keeps dark condition styles scoped to their target elements", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
|
||||
expect(source).not.toContain(":global(.dark) .data-grid");
|
||||
expect(source).toContain(":global(.dark .data-grid-topbar-condition-label--floating)");
|
||||
expect(source).toContain(":global(.dark .data-grid-topbar-condition-pane--expanded)");
|
||||
});
|
||||
|
||||
it("scrolls the caret into view after accepting a long completion", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
|
||||
expect(source).toContain("function scrollCaretIntoView()");
|
||||
expect(source).toContain("const hasVerticalOverflow = target.scrollHeight > target.clientHeight + 4");
|
||||
expect(source).toContain("const hasHorizontalOverflow = target.scrollWidth > target.clientWidth + 1");
|
||||
expect(source).toContain("target.scrollTop = 0");
|
||||
expect(source).toContain("const caretLeft = caretMarker.offsetLeft");
|
||||
expect(source).toContain("target.scrollLeft");
|
||||
expect(source).toContain("function scheduleCaretIntoView()");
|
||||
expect(source).toContain("function focusAfterAccept()");
|
||||
expect(source).toContain("requestAnimationFrame(() => scrollCaretIntoView())");
|
||||
expect(source).toContain("scheduleCaretIntoView()");
|
||||
expect(source).toContain('if (action === "accept") focusAfterAccept()');
|
||||
});
|
||||
|
||||
it("also keeps the caret visible after regular input wraps", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const onInputBody = source.match(/function onInput\(event: Event\) \{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
|
||||
expect(onInputBody).toContain("resizeEditor(true)");
|
||||
expect(onInputBody).toContain("updateSuggestionPosition()");
|
||||
expect(onInputBody).toContain("scheduleCaretIntoView()");
|
||||
});
|
||||
|
||||
it("keeps the caret visible after switching focus into the expanded editor", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const focusTransferBody = source.match(/if \(nextExpanded && document\.activeElement === input && !composing\.value\) \{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
|
||||
|
||||
expect(focusTransferBody).toContain("const start = selectionStart.value");
|
||||
expect(focusTransferBody).toContain("overlay.setSelectionRange(start, end)");
|
||||
expect(focusTransferBody).toContain("overlay.focus({ preventScroll: true })");
|
||||
expect(focusTransferBody).toContain("scheduleCaretIntoView()");
|
||||
});
|
||||
|
||||
it("keeps the caret visible after collapsing the expanded editor back to one line", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const collapseTransferBody = source.match(/if \(!nextExpanded && overlayFocused && !composing\.value\) \{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
|
||||
|
||||
expect(collapseTransferBody).toContain("const start = selectionStart.value");
|
||||
expect(collapseTransferBody).toContain("input.setSelectionRange(start, end)");
|
||||
expect(collapseTransferBody).toContain("input.focus({ preventScroll: true })");
|
||||
expect(collapseTransferBody!.indexOf("input.focus({ preventScroll: true })")).toBeLessThan(collapseTransferBody!.indexOf("input.setSelectionRange(start, end)"));
|
||||
expect(collapseTransferBody).toContain("scheduleCaretIntoView()");
|
||||
});
|
||||
|
||||
it("preserves continuous input when the expanded editor collapses", async () => {
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
const textWidth = (this.textContent?.length ?? 0) * 8;
|
||||
@@ -472,15 +379,6 @@ describe("DataGridConditionEditor quote completion", () => {
|
||||
expect(expandedHighlight?.style.transform).toBe("translate(0px, 0px)");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("positions suggestions below the measured expanded editor height", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const expandedPaneCss = source.match(/\.data-grid-topbar-condition-pane--expanded\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
|
||||
expect(source).toContain("bottom: expandedRect.value.top + expandedHeight.value");
|
||||
expect(expandedPaneCss).toContain("transition: box-shadow 150ms ease");
|
||||
expect(expandedPaneCss).not.toContain("height 150ms");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridConditionEditor Chinese column matching", () => {
|
||||
|
||||
@@ -1,34 +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 metadataLoaderSource = readFileSync(new URL("../../../composables/useDataGridTableMetadataLoaders.ts", import.meta.url), "utf8");
|
||||
|
||||
describe("DataGrid DDL search navigation", () => {
|
||||
it("resets navigation when the raw search query changes", () => {
|
||||
expect(dataGridSource).toMatch(/watch\(\s*searchQuery,\s*async \(\) => \{\s*ddlSearchMatchIndex\.value = 0;/);
|
||||
});
|
||||
|
||||
it("highlights the DDL once and marks the rendered DOM for the search", () => {
|
||||
// #9212: re-highlighting (and re-parsing) the whole DDL on every keystroke
|
||||
// froze the window, so the highlighted HTML only depends on the DDL itself
|
||||
// and the search marks are applied to the already-rendered <pre>.
|
||||
expect(dataGridSource).toMatch(/const highlightedDdlContent = computed\(\(\) => \(ddlContent\.value \? highlight\(ddlContent\.value\) : ""\)\);/);
|
||||
expect(dataGridSource).toContain('v-html="highlightedDdlContent"');
|
||||
expect(dataGridSource).toContain('import { applyDdlSearchMarks } from "@/lib/sql/ddlSearchMarks"');
|
||||
expect(dataGridSource).not.toContain("filteredDdlContent");
|
||||
expect(dataGridSource.match(/applyDdlSearchMarks\(ddlPreRef\.value, searchQuery\.value\)/g) ?? []).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("uses the shared DDL cache policy and exposes refresh controls", () => {
|
||||
expect(metadataLoaderSource).toContain('from "@/lib/metadata/objectDdlCache"');
|
||||
expect(metadataLoaderSource).toContain("async function fetchDdl(force = options.settingsStore.editorSettings.refreshDdlOnOpen)");
|
||||
expect(metadataLoaderSource).toMatch(/objectType: tableObjectSourceKind\(props\.tableMeta\.tableType\)/);
|
||||
expect(metadataLoaderSource).toContain("loadObjectDdl(request, { force })");
|
||||
expect(dataGridSource).toMatch(/async function refreshActiveTableInfo\(\)[\s\S]*?fetchDdl\(true\)[\s\S]*?fetchTableInfoColumns\(true\)[\s\S]*?fetchIndexes\(\)[\s\S]*?fetchForeignKeys\(\)[\s\S]*?fetchConstraints\(\)[\s\S]*?fetchTriggers\(\)/);
|
||||
expect(dataGridSource).toContain('@click="refreshActiveTableInfo"');
|
||||
expect(metadataLoaderSource).toMatch(/loadObjectMetadataFacet\(request, "columns"/);
|
||||
expect(dataGridSource).toMatch(/activeTableInfoTab\.value === "constraints"\) return constraintsLoading\.value/);
|
||||
expect(dataGridSource).not.toContain("setTableInfoRefreshDdlOnOpen");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adjacentDataGridDetailIndex, detailNavigationDelta, shouldIgnoreDataGridDetailNavigation } from "../../../lib/dataGrid/dataGridDetailNavigation";
|
||||
|
||||
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
|
||||
const detailDialogsSource = readFileSync(new URL("../DataGridDetailDialogs.vue", import.meta.url), "utf8");
|
||||
|
||||
function keyboardEvent(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
|
||||
return {
|
||||
key: "ArrowDown",
|
||||
@@ -25,12 +21,6 @@ function targetMatching(selectorPart: string): EventTarget {
|
||||
return { closest: (selector: string) => (selector.includes(selectorPart) ? {} : null) } as unknown as EventTarget;
|
||||
}
|
||||
|
||||
function functionBody(source: string, name: string, nextName: string): string {
|
||||
const start = source.indexOf(`function ${name}`);
|
||||
const end = source.indexOf(`function ${nextName}`, start + 1);
|
||||
return start >= 0 && end > start ? source.slice(start, end) : "";
|
||||
}
|
||||
|
||||
describe("DataGrid detail navigation index", () => {
|
||||
it("follows the supplied displayed order instead of deriving neighboring ids", () => {
|
||||
const displayedRows = [42, 7, 99];
|
||||
@@ -89,44 +79,4 @@ describe("DataGrid detail keyboard safety", () => {
|
||||
expect(shouldIgnoreDataGridDetailNavigation(keyboardEvent({ ctrlKey: true }))).toBe(true);
|
||||
expect(shouldIgnoreDataGridDetailNavigation(keyboardEvent({ defaultPrevented: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("wires row and column intents to the displayed DataGrid order", () => {
|
||||
expect(detailDialogsSource).toContain('@keydown="onRowDetailKeydown"');
|
||||
expect(detailDialogsSource).toContain('@keydown="onColumnDetailKeydown"');
|
||||
expect(detailDialogsSource).toContain("shouldIgnoreDataGridDetailNavigation(event)");
|
||||
expect(detailDialogsSource).toContain('detailNavigationDelta(event.key, "row")');
|
||||
expect(detailDialogsSource).toContain('detailNavigationDelta(event.key, "column")');
|
||||
expect(detailDialogsSource).toContain('emit("navigateRow", delta)');
|
||||
expect(detailDialogsSource).toContain('emit("navigateColumn", delta)');
|
||||
expect(dataGridSource).toContain('@navigate-row="navigateRowDetail"');
|
||||
expect(dataGridSource).toContain('@navigate-column="navigateColumnDetail"');
|
||||
|
||||
const rowNavigation = functionBody(dataGridSource, "navigateRowDetail", "navigateColumnDetail");
|
||||
const columnNavigation = functionBody(dataGridSource, "navigateColumnDetail", "openContextRowDetailDialog");
|
||||
|
||||
expect(rowNavigation).toContain("displayRowIndexById(rowDetailDialogRowId.value)");
|
||||
expect(rowNavigation).toContain("displayItemAt(nextRowIndex)");
|
||||
expect(rowNavigation).not.toMatch(/rowDetailDialogRowId\.value\s*[+-]/);
|
||||
expect(columnNavigation).toContain("visibleColumnIndexes.value.indexOf(columnDetailDialogColumnIndex.value)");
|
||||
expect(columnNavigation).toContain("visibleColumnIndexes.value[nextColumnPosition]");
|
||||
expect(columnNavigation).not.toMatch(/columnDetailDialogColumnIndex\.value\s*[+-]/);
|
||||
});
|
||||
|
||||
it("keeps the dialog open while replacing the detail target and guards closed dialogs", () => {
|
||||
const rowNavigation = functionBody(dataGridSource, "navigateRowDetail", "navigateColumnDetail");
|
||||
const columnNavigation = functionBody(dataGridSource, "navigateColumnDetail", "openContextRowDetailDialog");
|
||||
|
||||
expect(rowNavigation).toContain("if (!rowDetailDialogOpen.value || rowDetailDialogRowId.value === null) return;");
|
||||
expect(columnNavigation).toContain("if (!columnDetailDialogOpen.value || columnDetailDialogColumnIndex.value === null) return;");
|
||||
expect(rowNavigation).not.toContain("rowDetailDialogOpen.value = false");
|
||||
expect(columnNavigation).not.toContain("columnDetailDialogOpen.value = false");
|
||||
expect(detailDialogsSource).toContain('const rowSearch = ref("");');
|
||||
expect(detailDialogsSource).toContain('const columnSearch = ref("");');
|
||||
});
|
||||
|
||||
it("focuses the dialog content on open so arrows work immediately", () => {
|
||||
expect(detailDialogsSource).toContain('@open-auto-focus="focusDetailDialogContentOnOpen"');
|
||||
expect(detailDialogsSource).toContain('tabindex="-1"');
|
||||
expect(detailDialogsSource).toContain("function focusDetailDialogContentOnOpen(event: Event) {");
|
||||
});
|
||||
});
|
||||
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
|
||||
|
||||
function resultWatchSource(): string {
|
||||
const start = dataGridSource.indexOf("watch(\n () => props.result,\n (result, previousResult) => {");
|
||||
const end = dataGridSource.indexOf("// --- Context menu handlers ---");
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return dataGridSource.slice(start, end);
|
||||
}
|
||||
|
||||
function captureViewportAnchorForRefreshSource(): string {
|
||||
const start = dataGridSource.indexOf("function captureViewportAnchorForRefresh()");
|
||||
const end = dataGridSource.indexOf("function restoreViewportAnchorAfterRefresh(");
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return dataGridSource.slice(start, end);
|
||||
}
|
||||
|
||||
describe("DataGrid result-replacement vertical scroll", () => {
|
||||
it("resets to the first row when the result is replaced without an internal grid action", () => {
|
||||
const watchSource = resultWatchSource();
|
||||
|
||||
expect(watchSource).toContain("} else if (!inPlaceRefreshPending) {");
|
||||
const externalBranch = watchSource.slice(watchSource.indexOf("} else if (!inPlaceRefreshPending) {"));
|
||||
expect(externalBranch.indexOf("resetGridVerticalScroll();")).toBeGreaterThanOrEqual(0);
|
||||
expect(externalBranch).toContain("#7341");
|
||||
});
|
||||
|
||||
it("keeps the internal-action reset flag path unchanged", () => {
|
||||
const watchSource = resultWatchSource();
|
||||
|
||||
expect(watchSource).toContain("if (getResetScrollAfterResult()) {\n clearResetScrollAfterResult();\n resetGridVerticalScroll();");
|
||||
});
|
||||
|
||||
it("treats any pending prepareFullReload marker as an in-place refresh that keeps the viewport", () => {
|
||||
const watchSource = resultWatchSource();
|
||||
|
||||
expect(watchSource).toContain("preservedSelectionOnNextResult !== null || preservedViewportAnchorOnNextResult !== null || preservedDetailsOnNextResult !== null || preserveTransposeOnNextResult.value");
|
||||
// The pending-marker check must run before the watch consumes the markers.
|
||||
const pendingCheck = watchSource.indexOf("const inPlaceRefreshPending =");
|
||||
const selectionConsume = watchSource.indexOf("preservedSelectionOnNextResult = null;");
|
||||
const anchorConsume = watchSource.indexOf("preservedViewportAnchorOnNextResult = null;");
|
||||
const detailsConsume = watchSource.indexOf("preservedDetailsOnNextResult = null;");
|
||||
const transposeConsume = watchSource.indexOf("preserveTransposeOnNextResult.value = false;");
|
||||
expect(pendingCheck).toBeGreaterThanOrEqual(0);
|
||||
for (const consume of [selectionConsume, anchorConsume, detailsConsume, transposeConsume]) {
|
||||
expect(consume).toBeGreaterThan(pendingCheck);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns before any scroll reset when the replacement completes an infinite-scroll append", () => {
|
||||
const watchSource = resultWatchSource();
|
||||
|
||||
const appendReturn = watchSource.indexOf("if (appendCompletion) {");
|
||||
const appendEarlyReturn = watchSource.indexOf("return;\n }");
|
||||
const resetBranch = watchSource.indexOf("if (getResetScrollAfterResult()) {");
|
||||
expect(appendReturn).toBeGreaterThanOrEqual(0);
|
||||
expect(appendEarlyReturn).toBeGreaterThan(appendReturn);
|
||||
expect(resetBranch).toBeGreaterThan(appendEarlyReturn);
|
||||
});
|
||||
|
||||
it("does not anchor a toolbar refresh to the current top row when already scrolled to the top (#8339)", () => {
|
||||
// Anchoring the row that happens to be at scrollTop 0 would re-pin it to the
|
||||
// same on-screen offset after refresh, which pushes newly inserted rows
|
||||
// (e.g. rows prepended by an external insert under a DESC sort) above the
|
||||
// visible viewport instead of showing them. Skipping the anchor when already
|
||||
// at the top lets the toolbar-refresh path land on the freshest first row.
|
||||
const anchorSource = captureViewportAnchorForRefreshSource();
|
||||
|
||||
expect(anchorSource).toContain("#8339");
|
||||
const guard = anchorSource.indexOf("if (scroller.scrollTop <= 0) return null;");
|
||||
const rowHeightUse = anchorSource.indexOf("const rowHeight =");
|
||||
expect(guard).toBeGreaterThanOrEqual(0);
|
||||
expect(rowHeightUse).toBeGreaterThan(guard);
|
||||
});
|
||||
});
|
||||
-67
@@ -1,7 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { createApp, defineComponent, h, markRaw, nextTick, type App, type PropType } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -29,8 +27,6 @@ vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
|
||||
import DataGrid from "../DataGrid.vue";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
const dataGridSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGrid.vue"), "utf8");
|
||||
const appSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/App.vue"), "utf8");
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
const RecycleScroller = defineComponent({
|
||||
@@ -147,12 +143,6 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function functionBody(name: string, nextName: string): string {
|
||||
const start = dataGridSource.indexOf(`function ${name}`);
|
||||
const end = dataGridSource.indexOf(`function ${nextName}`, start + 1);
|
||||
return start >= 0 && end > start ? dataGridSource.slice(start, end) : "";
|
||||
}
|
||||
|
||||
describe("DataGrid go-to-column shortcut", () => {
|
||||
it("switches between the split and original single-row toolbar layouts", async () => {
|
||||
const { host, settingsStore } = mountGrid();
|
||||
@@ -299,61 +289,4 @@ describe("DataGrid go-to-column shortcut", () => {
|
||||
expect(bubbled).toHaveBeenCalledOnce();
|
||||
expect(goToColumnButton(host).getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("opens the existing popover through the shared grid action", () => {
|
||||
const keydown = functionBody("onGridKeydown", "copyDetailValue");
|
||||
|
||||
expect(dataGridSource).toMatch(/<div\b(?=[^>]*\bdata-grid-root)(?=[^>]*\btabindex="0")(?=[^>]*@keydown="onGridKeydown")[^>]*>/);
|
||||
expect(keydown).toMatch(
|
||||
/const targetAllowsNativeClipboard = eventTargetAllowsNativeClipboard\(event\);[\s\S]*?if \(!targetAllowsNativeClipboard && isGoToColumnShortcut\(event, settingsStore\.editorSettings\.shortcuts\) && openGoToColumn\(\)\) \{[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopPropagation\(\);[\s\S]*?return;[\s\S]*?\}/,
|
||||
);
|
||||
expect(dataGridSource).toContain("function openGoToColumn(): boolean");
|
||||
expect(dataGridSource).toContain("if (!displayableColumnIndexes.value.length) return false;");
|
||||
});
|
||||
|
||||
it("keeps editable targets available for the application-level fallback", () => {
|
||||
const keydown = functionBody("onGridKeydown", "copyDetailValue");
|
||||
const shortcutStart = keydown.indexOf("if (!targetAllowsNativeClipboard && isGoToColumnShortcut");
|
||||
const shortcutEnd = keydown.indexOf("if (isFocusSearchShortcut", shortcutStart);
|
||||
const shortcutBranch = keydown.slice(shortcutStart, shortcutEnd);
|
||||
|
||||
expect(shortcutStart).toBeGreaterThan(-1);
|
||||
expect(shortcutBranch).not.toContain("else");
|
||||
expect(shortcutBranch.match(/preventDefault/g)).toHaveLength(1);
|
||||
expect(shortcutBranch.match(/stopPropagation/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("gives the data-tab fallback priority over conflicting global shortcuts", () => {
|
||||
const keydown = functionBody("onGridKeydown", "copyDetailValue");
|
||||
|
||||
expect(keydown).toMatch(/if \(isFocusSearchShortcut\(event\) && !isGoToColumnShortcut\(event, settingsStore\.editorSettings\.shortcuts\)\)/);
|
||||
expect(appSource).toMatch(
|
||||
/const shortcuts = settingsStore\.editorSettings\.shortcuts;[\s\S]*?if \(showTabSwitcher\.value\) return;[\s\S]*?if \(isGoToColumnShortcut\(e, shortcuts\) && contentAreaRef\.value\?\.openGoToColumn\(\)\) \{[\s\S]*?return;[\s\S]*?const tabSwitcherDirection = tabSwitcherDirectionFromShortcut\(e, shortcuts\);/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps toolbar navigation and adds keyboard selection to the lookup", () => {
|
||||
const selectColumn = functionBody("scrollToColumn", "onGoToColumnKeydown");
|
||||
const escape = functionBody("onGoToColumnKeydown", "matchesTableInfoColumn");
|
||||
const scroll = functionBody("scrollToColumnIndex", "measureColumnHeaderText");
|
||||
|
||||
expect(dataGridSource.match(/<Popover v-model:open="goToColumnOpen">/g)).toHaveLength(1);
|
||||
expect(dataGridSource).toContain('>{{ t("grid.goToColumn") }}</span');
|
||||
expect(dataGridSource).toContain('v-model="goToColumnSearch"');
|
||||
expect(dataGridSource).toContain('ref="goToColumnSearchInput"');
|
||||
expect(dataGridSource).toContain('ref="goToColumnListRef"');
|
||||
expect(dataGridSource).toContain("filterDataGridColumnLookupItems(goToColumnItems.value, goToColumnSearch.value)");
|
||||
expect(dataGridSource).toContain("const goToColumnSelectedIndex = ref(0);");
|
||||
expect(selectColumn).toContain('goToColumnSearch.value = ""');
|
||||
expect(selectColumn).toContain("scrollToColumnIndex(columnIndex)");
|
||||
expect(escape).toMatch(/event\.key === "ArrowDown"[\s\S]*?moveGoToColumnSelection\(1\)/);
|
||||
expect(escape).toMatch(/event\.key === "ArrowUp"[\s\S]*?moveGoToColumnSelection\(-1\)/);
|
||||
expect(escape).toMatch(/event\.key === "Enter"[\s\S]*?scrollToColumn\(selected\.index\)/);
|
||||
expect(escape).toMatch(/event\.key === "Escape"[\s\S]*?goToColumnOpen\.value = false;[\s\S]*?goToColumnSearch\.value = ""/);
|
||||
expect(scroll).toContain("if (hiddenColumnIndexes.value.has(columnIndex))");
|
||||
expect(scroll).toContain("showColumn(columnIndex)");
|
||||
expect(scroll).toContain("highlightedColumnIndex.value = columnIndex");
|
||||
expect(scroll).toContain("scroller.scrollLeft = targetLeft");
|
||||
expect(scroll).toContain("updateGridHorizontalViewport(scroller)");
|
||||
});
|
||||
});
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionSource(name: string, nextName: string): string {
|
||||
const start = dataGridSource.indexOf(`function ${name}`);
|
||||
const relativeEnd = dataGridSource.slice(start).search(new RegExp(`\\n(?:async\\s+)?function\\s+${nextName}\\b`));
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(relativeEnd).toBeGreaterThan(0);
|
||||
const end = start + relativeEnd;
|
||||
return dataGridSource.slice(start, end);
|
||||
}
|
||||
|
||||
describe("DataGrid infinite-scroll sorting", () => {
|
||||
it("clears completed and pending infinite-scroll state before database sorting", () => {
|
||||
const sortSource = functionSource("applyColumnSort", "selectHeaderSort");
|
||||
const resetSource = functionSource("resetInfiniteScrollState", "onToolbarRefresh");
|
||||
|
||||
expect(sortSource).toContain('if (mode === "database" && infiniteScrollEnabled.value) {\n resetInfiniteScrollState();');
|
||||
expect(resetSource).toContain("lastInfiniteScrollPage = 0;");
|
||||
expect(resetSource).toContain("infiniteScrollAllLoaded = false;");
|
||||
expect(resetSource).toContain("infiniteScrollRequestedOffset = undefined;");
|
||||
expect(resetSource).toContain("infiniteScrollRequestedLimit = undefined;");
|
||||
expect(resetSource).toContain("isInfiniteScrollPaginating.value = false;");
|
||||
expect(resetSource).toContain("infiniteScrollLoading.value = false;");
|
||||
});
|
||||
|
||||
it("preserves the existing lightweight reset for local sorting", () => {
|
||||
const sortSource = functionSource("applyColumnSort", "selectHeaderSort");
|
||||
|
||||
expect(sortSource).toContain("} else {\n currentPage.value = 1;\n resetGridVerticalScroll(true);\n }");
|
||||
expect(sortSource.match(/emit\("sort"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not expose an explicit local sort as a database order for refresh or pagination", () => {
|
||||
const orderSource = functionSource("currentOrderBy", "executeServerPageJump");
|
||||
|
||||
expect(orderSource).toContain('sortMode.value === "database"');
|
||||
expect(orderSource).toContain("return orderByInput.value.trim() || structuredOrderBy;");
|
||||
});
|
||||
});
|
||||
-39
@@ -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");
|
||||
|
||||
describe("DataGrid setup initialization order", () => {
|
||||
it("initializes table column metadata before column width measurement", () => {
|
||||
const tableColumnMetadataDeclaration = dataGridSource.indexOf("const tableColumnsByResultIndex = computed");
|
||||
const columnResizeInitialization = dataGridSource.indexOf("useDataGridColumnResize({");
|
||||
|
||||
expect(tableColumnMetadataDeclaration).toBeGreaterThanOrEqual(0);
|
||||
expect(columnResizeInitialization).toBeGreaterThan(tableColumnMetadataDeclaration);
|
||||
});
|
||||
|
||||
it("initializes column formatters before measuring formatted column widths", () => {
|
||||
const columnFormatterDeclaration = dataGridSource.indexOf("} = useDataGridColumnFormatter({");
|
||||
const columnWidthInitialization = dataGridSource.lastIndexOf("initColumnWidths();");
|
||||
|
||||
expect(columnFormatterDeclaration).toBeGreaterThanOrEqual(0);
|
||||
expect(columnWidthInitialization).toBeGreaterThan(columnFormatterDeclaration);
|
||||
expect(dataGridSource).toContain("columnFormatterForWidth?.(columnIndex)");
|
||||
});
|
||||
|
||||
it("initializes where-search capability before the immediate filter preview watcher", () => {
|
||||
const canUseWhereSearchDeclaration = dataGridSource.indexOf("const canUseWhereSearch = computed");
|
||||
const filterPreviewDeclaration = dataGridSource.indexOf("const filterPreviewVisible = computed");
|
||||
const filterPreviewWatcher = dataGridSource.indexOf("[filterPreviewVisible, structuredFilterRules]");
|
||||
|
||||
expect(canUseWhereSearchDeclaration).toBeGreaterThanOrEqual(0);
|
||||
expect(filterPreviewDeclaration).toBeGreaterThan(canUseWhereSearchDeclaration);
|
||||
expect(filterPreviewWatcher).toBeGreaterThan(filterPreviewDeclaration);
|
||||
});
|
||||
|
||||
it("does not expose an uninitialized large-value runtime to startup callbacks", () => {
|
||||
expect(dataGridSource).toContain("let largeValueRuntime: ReturnType<typeof useDataGridLargeValues> | undefined;");
|
||||
expect(dataGridSource).toContain("largeValueRuntime?.scheduleVisibleLargeValuePreviewHydration(delay)");
|
||||
expect(dataGridSource).toContain("largeValueRuntime?.hydrateLargeValueCell(rowId, columnIndex) ?? false");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bulkEditInputToSqlValue, dataGridInlineBulkEditValue } from "@/lib/dataGrid/dataGridInlineBulkEdit";
|
||||
|
||||
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionBody(name: string, nextName: string): string {
|
||||
const start = dataGridSource.indexOf(`function ${name}`);
|
||||
const end = dataGridSource.indexOf(`function ${nextName}`, start + 1);
|
||||
return start >= 0 && end > start ? dataGridSource.slice(start, end) : "";
|
||||
}
|
||||
|
||||
describe("DataGrid inline bulk editing", () => {
|
||||
it.each([
|
||||
["1", "1"],
|
||||
@@ -27,18 +18,6 @@ describe("DataGrid inline bulk editing", () => {
|
||||
it.each([{ key: "v", ctrlKey: true }, { key: "1", altKey: true }, { key: "删", isComposing: true }, { key: "删", keyCode: 229 }, { key: "Process" }])("ignores shortcut, composition, and control keys: %j", (event) => {
|
||||
expect(dataGridInlineBulkEditValue(event, 2)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("starts a cell editor instead of opening the bulk edit dialog", () => {
|
||||
const keydown = functionBody("onGridKeydown", "copyDetailValue");
|
||||
expect(keydown).toContain("beginInlineBulkEdit(inlineBulkEditValue)");
|
||||
expect(keydown).not.toContain("openBulkEditDialog");
|
||||
});
|
||||
|
||||
it("commits the inline value to the full selection", () => {
|
||||
const commit = functionBody("commitInlineBulkEdit", "commitBooleanGridEdit");
|
||||
expect(commit).toContain("fillSelectionWithValue(nextValue, { emptyStringAsNull: true })");
|
||||
expect(commit).toContain("cancelEdit()");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkEditInputToSqlValue", () => {
|
||||
@@ -49,10 +28,4 @@ describe("bulkEditInputToSqlValue", () => {
|
||||
it.each(["0", "false", "NULLL", "NULLX", "'NULL'", "nulls", "NUL", " 1 "])("keeps non-sentinel input %j as typed", (input) => {
|
||||
expect(bulkEditInputToSqlValue(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("routes applyBulkEditValue through bulkEditInputToSqlValue for both scopes", () => {
|
||||
const applyBulkEdit = functionBody("applyBulkEditValue", "confirmConditionalBulkEdit");
|
||||
expect(applyBulkEdit).toContain("bulkEditInputToSqlValue(bulkEditValue.value)");
|
||||
expect(applyBulkEdit).not.toContain('bulkEditValue.value === "" ? null');
|
||||
});
|
||||
});
|
||||
|
||||
-30
@@ -1,30 +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");
|
||||
|
||||
function functionSource(name: string, nextName: string): string {
|
||||
const start = largeValueSource.indexOf(`function ${name}`);
|
||||
const relativeEnd = largeValueSource.slice(start).search(new RegExp(`\\n\\s*(?:async\\s+)?function\\s+${nextName}\\b`));
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(relativeEnd).toBeGreaterThan(0);
|
||||
return largeValueSource.slice(start, start + relativeEnd);
|
||||
}
|
||||
|
||||
describe("DataGrid large-value reload SQL", () => {
|
||||
it("projects the synthetic rowid through the rowid-wrapped select on full-value reloads", () => {
|
||||
const fetchChunkSource = functionSource("fetchLargeValueRequestChunk", "resolveLargeValueCells");
|
||||
|
||||
// Keyless Oracle tables address rows via the hidden __DBX_ROWID alias; the
|
||||
// reload must opt into the ROWIDTOCHAR inline view so the generated SQL
|
||||
// never references __DBX_ROWID as a base-table column (ORA-00904).
|
||||
expect(fetchChunkSource).toContain("includeRowId: shouldIncludeSyntheticRowId(options.databaseType.value, tableMeta.primaryKeys, tableMeta.tableType)");
|
||||
});
|
||||
|
||||
it("keeps the visible-preview hydration consistent with the synthetic key", () => {
|
||||
const hydrateSource = functionSource("hydrateVisibleLargeValuePreviews", "runVisibleLargeValuePreviewHydration");
|
||||
|
||||
expect(hydrateSource).toContain("includeRowId: shouldIncludeSyntheticRowId(options.databaseType.value, tableMeta.primaryKeys, tableMeta.tableType)");
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionSource(name: string, nextName: string): string {
|
||||
const start = dataGridSource.indexOf(`function ${name}`);
|
||||
const relativeEnd = dataGridSource.slice(start).search(new RegExp(`\\n(?:async\\s+)?function\\s+${nextName}\\b`));
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(relativeEnd).toBeGreaterThan(0);
|
||||
return dataGridSource.slice(start, start + relativeEnd);
|
||||
}
|
||||
|
||||
describe("DataGrid large-value replacement", () => {
|
||||
it("counts preview matches and resolves full values before staging replacements", () => {
|
||||
const eligibilitySource = functionSource("canReplaceGridCell", "replacementCellInScope");
|
||||
const replaceSource = functionSource("replaceGridMatches", "fillSelectionWithValue");
|
||||
|
||||
expect(eligibilitySource).not.toContain("!isLargeValuePreview");
|
||||
expect(replaceSource).toContain("await prepareDataGridCellReplacements");
|
||||
expect(replaceSource).toContain("resolveLargeValueCells");
|
||||
});
|
||||
});
|
||||
-13
@@ -1,7 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { createApp, defineComponent, h, markRaw, nextTick, type App, type PropType } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -30,7 +28,6 @@ vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
|
||||
import DataGrid from "../DataGrid.vue";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
const dataGridSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGrid.vue"), "utf8");
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
const shortcutCases = [
|
||||
{ actionId: "goToFirstPage", key: "F1", offset: 0, functionName: "firstPage" },
|
||||
@@ -300,16 +297,6 @@ describe("DataGrid pagination shortcuts", () => {
|
||||
expect(paginate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes shortcuts to the existing pagination functions and preserves PageUp/PageDown navigation", () => {
|
||||
expect(dataGridSource).toMatch(/if \(!targetAllowsNativeClipboard && handleGridPaginationShortcut\(event\)\) return;/);
|
||||
for (const { actionId, functionName } of shortcutCases) {
|
||||
expect(dataGridSource).toContain(`is${actionId[0]!.toUpperCase()}${actionId.slice(1)}Shortcut(event, shortcuts)`);
|
||||
expect(dataGridSource).toContain(`navigate = ${functionName}`);
|
||||
}
|
||||
expect(dataGridSource).toContain('event.key === "PageUp" && navigateSelectedCell("pageUp", event.shiftKey)');
|
||||
expect(dataGridSource).toContain('event.key === "PageDown" && navigateSelectedCell("pageDown", event.shiftKey)');
|
||||
});
|
||||
|
||||
it("keeps legacy settings without pagination mappings keyboard-neutral", async () => {
|
||||
const { host, paginate } = mountGrid({ configurePaginationShortcuts: false });
|
||||
await settle();
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("DataGrid pending row visibility under local column filters", () => {
|
||||
it("keeps pending new rows visible while a local column filter is active", () => {
|
||||
// The pending-row branch of displayRowRefs must not consult the local
|
||||
// column filter matchers; only the row status filter applies (#9201).
|
||||
expect(dataGridSource).toMatch(/const row = newRows\.value\[newIndex\];[\s\S]{0,200}?if \(!row\) continue;/);
|
||||
expect(dataGridSource).not.toContain("!rowMatchesLocalColumnFilters(row)");
|
||||
});
|
||||
});
|
||||
-11
@@ -1,13 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import DataGridReadonlyTextSelection from "../DataGridReadonlyTextSelection.vue";
|
||||
|
||||
const mountedRoots: HTMLDivElement[] = [];
|
||||
const dataGridSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGrid.vue"), "utf8");
|
||||
|
||||
function mountSelection(options: { value?: string; expanded?: boolean } = {}) {
|
||||
const root = document.createElement("div");
|
||||
@@ -30,14 +27,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("DataGridReadonlyTextSelection", () => {
|
||||
it("closes a stale read-only selection when a result is replaced", () => {
|
||||
const resultWatcher = dataGridSource.match(/watch\(\s*\(\) => props\.result,\s*\(result, previousResult\) => \{[\s\S]*?\n\);/)?.[0] ?? "";
|
||||
|
||||
expect(resultWatcher).toContain("const appendCompletion = dataGridInfiniteScrollAppendCompletion(previousResult, result");
|
||||
expect(resultWatcher).toContain("if (appendCompletion) {");
|
||||
expect(resultWatcher).toContain("closeReadonlyCellTextSelection();");
|
||||
});
|
||||
|
||||
it("focuses and selects the complete read-only value on mount", async () => {
|
||||
const { root } = mountSelection();
|
||||
await nextTick();
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionSource(name: string, nextName: string): string {
|
||||
const start = dataGridSource.indexOf(`function ${name}`);
|
||||
const relativeEnd = dataGridSource.slice(start).search(new RegExp(`\\n(?:async\\s+)?function\\s+${nextName}\\b`));
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(relativeEnd).toBeGreaterThan(0);
|
||||
return dataGridSource.slice(start, start + relativeEnd);
|
||||
}
|
||||
|
||||
describe("DataGrid save reload integration", () => {
|
||||
it("shares the toolbar full-reload preparation with editor saves", () => {
|
||||
const prepareSource = functionSource("prepareFullReload", "onToolbarRefresh");
|
||||
const toolbarSource = functionSource("onToolbarRefresh", "setAutoRefreshInterval");
|
||||
const rollbackSource = functionSource("onToolbarRollback", "addRow");
|
||||
|
||||
expect(dataGridSource).toContain("prepareFullReload,\n emit,");
|
||||
expect(dataGridSource).toContain("refreshSavedRows,\n onCellValueChanged");
|
||||
expect(toolbarSource).toContain("prepareFullReload();");
|
||||
expect(rollbackSource).toContain("prepareFullReload();");
|
||||
expect(toolbarSource).toContain("const resetToFirstPage = hasPendingConditionInputs();");
|
||||
expect(toolbarSource).toContain("resetToFirstPage ? 0 : (currentPage.value - 1) * pageSize.value");
|
||||
expect(prepareSource).toContain("resetInfiniteScrollState();");
|
||||
expect(prepareSource).toContain("captureViewportAnchorForRefresh();");
|
||||
expect(prepareSource).toContain("preservedViewportAnchorOnNextResult = viewportAnchor ? { anchor: viewportAnchor, sourceResult: props.result } : null;");
|
||||
expect(prepareSource).toContain("captureCurrentSelectionForRefresh();");
|
||||
expect(prepareSource).toContain("preservedSelectionOnNextResult = selection ? { selection, sourceResult: props.result } : null;");
|
||||
expect(prepareSource).toContain("preservedDetailsOnNextResult = captureDetailsForRefresh();");
|
||||
expect(prepareSource).toContain("preserveTransposeOnNextResult.value = showTranspose.value;");
|
||||
expect(dataGridSource).toContain("if (detailsSnapshot) restoreDetailsAfterRefresh(detailsSnapshot);");
|
||||
expect(dataGridSource).toContain("if (viewportAnchorSnapshot) restoreViewportAnchorAfterRefresh(viewportAnchorSnapshot);");
|
||||
});
|
||||
|
||||
it("resets accumulated infinite-scroll pagination before reloading from offset zero", () => {
|
||||
const resetSource = functionSource("resetInfiniteScrollState", "prepareFullReload");
|
||||
const nextPageSource = functionSource("infiniteScrollNextPage", "checkInfiniteScroll");
|
||||
|
||||
expect(resetSource).toContain("currentPage.value = 1;");
|
||||
expect(resetSource).toContain("lastInfiniteScrollPage = 0;");
|
||||
expect(resetSource).toContain("infiniteScrollRequestedOffset = undefined;");
|
||||
expect(resetSource).toContain("infiniteScrollRequestedLimit = undefined;");
|
||||
expect(nextPageSource).toContain("const nextOffset = props.result.rows.length;");
|
||||
expect(nextPageSource).toContain('emit("paginate", nextOffset, nextLimit, currentWhereInput(), currentOrderBy());');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("DataGrid selection gesture ownership", () => {
|
||||
it("stops released gestures before DOM hover or canvas movement extends them", () => {
|
||||
expect(source).toContain("function stopReleasedSelectionGesture(event: MouseEvent)");
|
||||
expect(source).toContain("if ((event.buttons & 1) !== 0) return false;");
|
||||
expect(source).toContain("function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number, event: MouseEvent)");
|
||||
expect(source).toContain('@mouseenter="onCellMouseenter(item.displayIndex, col.visibleColIdx, col.actualColIdx, $event)"');
|
||||
|
||||
const canvasMove = source.slice(source.indexOf("function onCanvasMouseMove"), source.indexOf("function onCanvasMouseLeave"));
|
||||
expect(canvasMove).toContain("stopReleasedSelectionGesture(event);");
|
||||
});
|
||||
|
||||
it("only exposes cell snapshots for selections with a rectangular matrix", () => {
|
||||
expect(source).toMatch(/contextCell\.value && hasCellSelection\.value && selectedCellMatrix\.value\s*\n?\s*\?\s*"cells"/);
|
||||
});
|
||||
});
|
||||
@@ -115,13 +115,9 @@ import DataGridPagination from "@/components/grid/DataGridPagination.vue";
|
||||
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
|
||||
import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
|
||||
|
||||
const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const dataGridCellDetailEditSource = readFileSync("apps/desktop/src/composables/useDataGridCellDetailEdit.ts", "utf8");
|
||||
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 cellDetailHeaderSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailHeader.vue", "utf8");
|
||||
const globalsCss = readFileSync("apps/desktop/src/styles/globals.css", "utf8");
|
||||
|
||||
function detail(patch: Partial<DataGridCellDetail> = {}): DataGridCellDetail {
|
||||
return {
|
||||
@@ -154,55 +150,6 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.removeItem("dbx-filter-builder-value-shortcut-hint-days");
|
||||
});
|
||||
describe("DataGrid canvas surfaces", () => {
|
||||
it("keeps condition and text filter editors open when persistent expansion is enabled", () => {
|
||||
expect(dataGridSource).toContain('const isPersistentFilterView = computed(() => filterEditorView.value === "conditions" || filterEditorView.value === "text");');
|
||||
expect(dataGridSource).toContain("const isFilterEditorPinnedOpen = computed(() => isPersistentFilterView.value && settingsStore.editorSettings.dataGridKeepFilterEditorExpanded);");
|
||||
expect(dataGridSource).toContain("get: () => isFilterEditorPinnedOpen.value || filterBuilderOpen.value,");
|
||||
expect(dataGridSource).toContain("if (!isFilterEditorPinnedOpen.value) filterBuilderOpen.value = false;");
|
||||
expect(dataGridSource).toContain('v-model:filter-builder-open="effectiveFilterBuilderOpen"');
|
||||
expect(dataGridSource).toContain("filterEditorView === 'conditions' && effectiveFilterBuilderOpen");
|
||||
expect(dataGridSource).toContain("filterEditorView === 'text' && effectiveFilterBuilderOpen");
|
||||
});
|
||||
|
||||
it("asks before an expensive Elasticsearch cursor jump", () => {
|
||||
expect(dataGridSource).toContain("requestCount >= ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS");
|
||||
expect(dataGridSource).toContain('t("grid.esDeepPageJumpConfirmMessage"');
|
||||
expect(dataGridSource).toContain('@click="confirmEsDeepPageJump"');
|
||||
});
|
||||
|
||||
it("shows stable request progress while an Elasticsearch page jump is running", () => {
|
||||
expect(dataGridSource).toContain('t("grid.pageJumpLoading", { page: pageJumpProgress.targetPage })');
|
||||
expect(dataGridSource).toContain('t("grid.pageJumpProgress", { current: pageJumpProgress.completedRequests, total: pageJumpProgress.totalRequests })');
|
||||
expect(dataGridSource).toContain('role="progressbar"');
|
||||
expect(dataGridSource).toContain(':style="{ width: `${pageJumpProgressPercent}%` }"');
|
||||
});
|
||||
|
||||
it("uses the stable overlay for viewport and device-pixel measurement", () => {
|
||||
expect(dataGridSource).toContain("function canvasMeasurementSurface(): HTMLElement | null");
|
||||
expect(dataGridSource).toContain("return canvasOverlayRef.value ?? null;");
|
||||
expect(dataGridSource).toContain("const measurementSurface = canvasMeasurementSurface();");
|
||||
expect(dataGridSource).toContain("getSurface: canvasMeasurementSurface,");
|
||||
expect(dataGridSource).toContain("const canvas = inactiveCanvasSurface();");
|
||||
expect(dataGridSource).toContain("canvasUsingBackSurface.value = !canvasUsingBackSurface.value;");
|
||||
});
|
||||
|
||||
it("uses the canvas that actually received the event during a surface flip", () => {
|
||||
expect(dataGridSource).toContain("function canvasEventSurface(event: MouseEvent): HTMLCanvasElement | null");
|
||||
expect(dataGridSource).toContain("const currentTarget = event.currentTarget;");
|
||||
expect(dataGridSource).toContain("return currentTarget instanceof HTMLCanvasElement ? currentTarget : activeCanvasSurface();");
|
||||
expect(dataGridSource).toContain("const canvas = canvasEventSurface(event);");
|
||||
|
||||
const canvasMouseMove = dataGridSource.slice(dataGridSource.indexOf("function onCanvasMouseMove"), dataGridSource.indexOf("function onCanvasMouseLeave"));
|
||||
expect(canvasMouseMove).toContain("const cursorSurface = canvasEventSurface(event);");
|
||||
});
|
||||
|
||||
it("handles double clicks on the stable canvas container", () => {
|
||||
expect(dataGridSource).toContain('@dblclick="onCanvasDblClick"');
|
||||
expect(dataGridSource.match(/@dblclick="onCanvasDblClick"/g)).toHaveLength(1);
|
||||
expect(dataGridSource).toContain("@dblclick.stop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridSearchBar", () => {
|
||||
it("focuses/selects the input and forwards keyboard, navigation, and suggestion interactions", async () => {
|
||||
@@ -258,18 +205,6 @@ describe("DataGridSearchBar", () => {
|
||||
});
|
||||
|
||||
describe("DataGridPagination", () => {
|
||||
it("keeps drag summaries count-only and wires the derived average without materializing selected cells", () => {
|
||||
const summaryStart = dataGridSource.indexOf("const selectionSummary = computed");
|
||||
const pendingSummary = dataGridSource.indexOf("createPendingSelectionSummary(selectedCellCount.value, multiRowCount.value)", summaryStart);
|
||||
const materializedSummary = dataGridSource.indexOf("summarizeSelection(selectedCells.value)", summaryStart);
|
||||
|
||||
expect(summaryStart).toBeGreaterThan(-1);
|
||||
expect(pendingSummary).toBeGreaterThan(summaryStart);
|
||||
expect(materializedSummary).toBeGreaterThan(pendingSummary);
|
||||
expect(dataGridSource).toContain('const selectionSummaryAverageText = computed(() => {\n if (isSelectingCells.value) return "…";');
|
||||
expect(dataGridSource).toContain(':selection-summary-average-text="selectionSummaryAverageText"');
|
||||
});
|
||||
|
||||
it("shows average beside the existing selection summary values", () => {
|
||||
const mounted = mountComponent(DataGridPagination, {
|
||||
selectionSummary: { cellCount: 4, rowCount: 2 },
|
||||
@@ -507,8 +442,6 @@ describe("DataGridColumnHeader", () => {
|
||||
expect(String(tooltipType.props.class ?? "")).toContain("data-grid-type-string");
|
||||
const headerTypeLine = findOne(mounted.root, (node) => hostText(node) === "varchar(255)" && node.props["data-grid-header-type-line"] === "");
|
||||
expect(String(headerTypeLine.props.class)).toContain("data-grid-type-string");
|
||||
// Softer dark-mode palette override for the tooltip container.
|
||||
expect(globalsCss).toMatch(/\.dark \.dbx-column-info-tooltip \{[^}]*--data-grid-type-string-fg: #4ade80/);
|
||||
});
|
||||
|
||||
it("cancels resize-handle clicks without leaking header click events", () => {
|
||||
@@ -1423,38 +1356,6 @@ describe("DataGridTextFilterWorkbench", () => {
|
||||
});
|
||||
|
||||
describe("cell detail surfaces", () => {
|
||||
it("keeps detail tabs and editor actions usable when the panel narrows", () => {
|
||||
const tabsHeader = cellDetailHeaderSource;
|
||||
const tabViewport = tabsHeader.match(/<div class="([^"]*overflow-x-auto[^"]*)">\s*<TabsList/);
|
||||
const tabList = tabsHeader.match(/<TabsList class="([^"]+)">/);
|
||||
const triggerClasses = Array.from(tabsHeader.matchAll(/<TabsTrigger\b[^>]*class="([^"]+)"/g), ([, classes]) => classes.split(/\s+/));
|
||||
|
||||
expect(tabViewport?.[1]?.split(/\s+/)).toEqual(expect.arrayContaining(["min-w-0", "flex-1", "overflow-x-auto"]));
|
||||
expect(tabList?.[1]?.split(/\s+/)).toEqual(expect.arrayContaining(["flex", "w-max", "min-w-full"]));
|
||||
expect(triggerClasses).toHaveLength(3);
|
||||
for (const classes of triggerClasses) {
|
||||
expect(classes).toEqual(expect.arrayContaining(["min-w-max", "flex-1", "shrink-0"]));
|
||||
}
|
||||
expect(dataGridSource).not.toContain("activeCellDetailTabsGridClass");
|
||||
|
||||
const valueEditorLifecycleStart = dataGridSource.indexOf("watch(valueEditorContainer");
|
||||
const valueEditorLifecycleEnd = dataGridSource.indexOf("const detailEdit = useDataGridCellDetailEdit", valueEditorLifecycleStart);
|
||||
const valueEditorLifecycle = dataGridSource.slice(valueEditorLifecycleStart, valueEditorLifecycleEnd);
|
||||
expect(valueEditorLifecycle).toContain("const editor = valueDetailEditor;");
|
||||
expect(valueEditorLifecycle).toContain("if (valueDetailEditor !== editor) return;");
|
||||
expect(valueEditorLifecycle).toContain("if (editor.getValue() !== detailEditValue.value)");
|
||||
expect(valueEditorLifecycle).toContain("editor.setValue(detailEditValue.value, activeCellDetail.value?.type);");
|
||||
|
||||
const valueEditorStart = dataGridSource.indexOf("<TabsContent v-if=\"activeCellDetailTabs.includes('valueEditor')\"");
|
||||
const valueEditorEnd = dataGridSource.indexOf("</TabsContent>", valueEditorStart);
|
||||
const valueEditor = dataGridSource.slice(valueEditorStart, valueEditorEnd);
|
||||
expect(valueEditor).toMatch(/<TabsContent[^>]*class="[^"]*\bmin-w-0\b[^"]*">/);
|
||||
expect(valueEditor).toMatch(/<div class="[^"]*\bmin-w-0\b[^"]*\bflex-wrap\b[^"]*">/);
|
||||
|
||||
expect(cellDetailPanelSource).toMatch(/<TabsContent value="details" class="[^"]*\bmin-w-0\b[^"]*">/);
|
||||
expect(cellDetailPanelSource).toMatch(/<div class="[^"]*\bmin-w-0\b[^"]*\bflex-wrap\b[^"]*">/);
|
||||
});
|
||||
|
||||
it("presents printable LONG BLOB bytes as text and copies the presented value", async () => {
|
||||
const copyText = vi.fn();
|
||||
const blobDetail = detail({
|
||||
@@ -1857,14 +1758,6 @@ describe("cell detail surfaces", () => {
|
||||
expect(compareJson).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("snapshots comparison values before opening and suppresses modal-induced blur commits", () => {
|
||||
expect(dataGridCellDetailEditSource).toContain("detailValueDiffSnapshot.value = snapshot;");
|
||||
expect(dataGridCellDetailEditSource).toContain("detailValueDiffOpen.value = true;");
|
||||
expect(dataGridSource).toContain("if (!detailValueDiffOpen.value && !detailTransformOpen.value) commitValueEditorEdit();");
|
||||
expect(dataGridSource).toContain(':disabled="!canCompareDetailJson" @mousedown.prevent @click="openDetailJsonCompare"');
|
||||
expect(dataGridSource).toContain('v-model:open="detailValueDiffOpen" :snapshot="detailValueDiffSnapshot"');
|
||||
});
|
||||
|
||||
// issue #9832:编辑中点「压缩 JSON」会把草稿压成单行,此时必须仍能点「格式化 JSON」
|
||||
// 重新展开,否则 Mongo 这类对象字段的 JSON 一旦压缩就再也无法格式化。
|
||||
it("keeps the JSON format action reachable while a compacted draft is being edited", async () => {
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionSource(name: string, endMarker: string): string {
|
||||
const start = dataGridSource.indexOf(`function ${name}(`);
|
||||
const end = dataGridSource.indexOf(endMarker, start + 1);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return dataGridSource.slice(start, end);
|
||||
}
|
||||
|
||||
describe("DataGrid tab-switch view snapshots", () => {
|
||||
const captureSource = functionSource("captureViewSelection", "function captureTabSwitchViewSnapshot(");
|
||||
const captureFnSource = functionSource("captureTabSwitchViewSnapshot", "function restoreTabSwitchViewSnapshot(");
|
||||
const restoreSource = functionSource("restoreTabSwitchViewSnapshot", "const multiRowCount = computed(");
|
||||
|
||||
it("captures only with an owner key and a generation, and skips transpose", () => {
|
||||
expect(captureFnSource).toContain("if (!ownerKey || !props.viewGeneration) return;");
|
||||
expect(captureFnSource).toContain("if (showTranspose.value) return;");
|
||||
expect(captureFnSource).toContain("ownerKey,");
|
||||
expect(captureFnSource).toContain("viewGeneration: props.viewGeneration");
|
||||
expect(captureFnSource).toContain("probe: currentViewProbe()");
|
||||
});
|
||||
|
||||
it("takes the snapshot owner from viewStateKey, falling back to cacheKey", () => {
|
||||
// Document tabs need their own owner key: cacheKey also scopes structured
|
||||
// filters, column widths and pending edits, which they leave untouched.
|
||||
expect(dataGridSource).toContain("const viewSnapshotOwnerKey = computed(() => props.viewStateKey?.trim() || props.cacheKey?.trim() || undefined);");
|
||||
expect(captureFnSource).toContain("const ownerKey = viewSnapshotOwnerKey.value;");
|
||||
expect(restoreSource).toContain("const ownerKey = viewSnapshotOwnerKey.value;");
|
||||
});
|
||||
|
||||
it("captures the selection in O(selection size), never through the refresh identity scan", () => {
|
||||
// Copies refs directly instead of captureCurrentSelectionForRefresh, which
|
||||
// builds an identity token for every row (O(row count) on a large result).
|
||||
expect(captureFnSource).not.toContain("captureCurrentSelectionForRefresh");
|
||||
// Contiguous/all row selections serialize as a compact range instead of one
|
||||
// id per row, and the range helpers iterate only the selected ids via the
|
||||
// O(1) display index lookup — never the full displayItems.
|
||||
expect(captureSource).toContain("selectedRowCount === displayCount");
|
||||
expect(captureSource).toContain("selectedRowsContiguous(selectedRowIds.value)");
|
||||
expect(captureSource).toContain('kind: "all"');
|
||||
expect(captureSource).toContain("clampDataGridViewSelection(");
|
||||
const contiguousHelper = dataGridSource.slice(dataGridSource.indexOf("function selectedRowsContiguous("), dataGridSource.indexOf("function minimalRowRange("));
|
||||
expect(contiguousHelper).toContain("displayRowIndexById(rowId)");
|
||||
expect(contiguousHelper).toContain("max - min + 1 === count");
|
||||
expect(contiguousHelper).not.toContain("displayItems");
|
||||
});
|
||||
|
||||
it("gates every restore on identity, renderer, and probe", () => {
|
||||
expect(restoreSource).toContain("if (!structuredFilterHydrationReady.value) return;");
|
||||
expect(restoreSource).toContain("if (snapshot.viewGeneration !== props.viewGeneration) return;");
|
||||
expect(restoreSource).toContain('if (snapshot.renderer !== (useCanvasGridRows.value ? "canvas" : "dom")) return;');
|
||||
expect(restoreSource).toContain("if (snapshot.probe !== currentViewProbe()) return;");
|
||||
expect(restoreSource).toContain("if (showTranspose.value) return;");
|
||||
});
|
||||
|
||||
it("applies the index-based selection directly without scrolling it into view", () => {
|
||||
expect(restoreSource).toContain("restoreCellSelectionState({ cellKeys: new Set(capturedSelection.cellKeys) })");
|
||||
expect(restoreSource).toContain('selectedRowIds.value = new Set(indexes.map((index) => displayItems.value[index]?.id).filter((id): id is number => typeof id === "number"));');
|
||||
expect(restoreSource).toContain("selectedColumnIndexes.value = new Set(capturedSelection.columnIndexes);");
|
||||
expect(restoreSource).toContain("selectedRowIds.value = new Set(displayItems.value.map((item) => item.id));");
|
||||
expect(restoreSource).toContain("selection.lastClickedRowIndex.value = capturedSelection.anchorRowIndex;");
|
||||
expect(restoreSource).not.toContain("restoreSelectionAfterRefresh(snapshot");
|
||||
});
|
||||
|
||||
it("clamps the restored viewport and consumes the snapshot after success", () => {
|
||||
expect(restoreSource).toContain("Math.min(Math.max(0, snapshot.viewport.top), maxTop)");
|
||||
expect(restoreSource).toContain("Math.min(Math.max(0, snapshot.viewport.left), maxLeft)");
|
||||
// A replayed snapshot is consumed so a later remount cannot re-apply it.
|
||||
expect(restoreSource).toContain("consumeDataGridViewSnapshot(ownerKey);");
|
||||
});
|
||||
|
||||
it("settles the viewport over a bounded retry envelope instead of a fixed two frames", () => {
|
||||
// Immediate attempt, then nextTick, then at most 8 rAFs — mirroring
|
||||
// restoreScrollAcrossFrames. A fixed two-frame assumption intermittently
|
||||
// clamps the saved position to the top before virtualization measures.
|
||||
expect(restoreSource).toContain("if (attempt()) return;");
|
||||
expect(restoreSource).toContain("nextTick(() => {");
|
||||
expect(restoreSource).toContain("frames >= MAX_VIEW_SNAPSHOT_RESTORE_FRAMES");
|
||||
expect(restoreSource).toContain("viewSnapshotRestoreFrame = requestAnimationFrame(onFrame);");
|
||||
// An unmeasured scroller clamps the target to 0; that must not count as a
|
||||
// finished restore, or the saved position is lost at the top.
|
||||
expect(restoreSource).toContain("const roomForSavedTop = maxTop >= snapshot.viewport.top;");
|
||||
expect(restoreSource).toContain("return accepted && (snapshot.viewport.top === 0 || (roomForSavedTop && stable));");
|
||||
expect(dataGridSource).toContain("const MAX_VIEW_SNAPSHOT_RESTORE_FRAMES = 8;");
|
||||
});
|
||||
|
||||
it("honours the rollback switch in both directions", () => {
|
||||
expect(captureFnSource).toContain("if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;");
|
||||
expect(restoreSource).toContain("if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;");
|
||||
expect(dataGridSource).toContain("cancelViewSnapshotRestoreFrame();");
|
||||
});
|
||||
|
||||
it("wires capture to unmount and to the pre-tab-switch event", () => {
|
||||
expect(dataGridSource).toContain('window.addEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot)');
|
||||
expect(dataGridSource).toContain('window.removeEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot)');
|
||||
expect(dataGridSource).toContain("onUnmounted(() => {\n // Capture before teardown");
|
||||
expect(dataGridSource).toContain("captureTabSwitchViewSnapshot();");
|
||||
});
|
||||
|
||||
it("reports a dropped selection only after a successful restore", () => {
|
||||
// The capture must not toast while the user is leaving the tab.
|
||||
expect(captureFnSource).not.toContain("toast(");
|
||||
expect(restoreSource).toContain("snapshot.selectionDropped && shouldNotifyOverBudgetSelection(ownerKey");
|
||||
expect(restoreSource).toContain('toast(t("grid.viewSnapshotSelectionNotRestored")');
|
||||
// The notice stays one-shot per owner+generation.
|
||||
expect(dataGridSource).toContain("shouldNotifyOverBudgetSelection(ownerKey, props.viewGeneration!)");
|
||||
});
|
||||
|
||||
it("retries after asynchronous structured-filter hydration", () => {
|
||||
expect(dataGridSource).toContain("structuredFilterHydrationReady.value = false;");
|
||||
expect(dataGridSource).toContain("structuredFilterHydrationReady.value = true;");
|
||||
expect(dataGridSource).toContain("nextTick(restoreTabSwitchViewSnapshot);");
|
||||
expect(dataGridSource).toContain("structuredFilterHydrationRequestId");
|
||||
});
|
||||
});
|
||||
-6
@@ -1,7 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
@@ -41,8 +39,6 @@ vi.mock("@/components/ui/button", async () => {
|
||||
|
||||
import DataGridColumnLayoutPopover from "../DataGridColumnLayoutPopover.vue";
|
||||
|
||||
const contentAreaSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/layout/ContentArea.vue"), "utf8");
|
||||
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
function columnLayoutOptions(itemCount: number): DataGridColumnLayoutOption[] {
|
||||
@@ -175,8 +171,6 @@ afterEach(() => {
|
||||
|
||||
describe("data grid column layout popover", () => {
|
||||
it("is available from the query result toolbar and keeps a labelled compact trigger", async () => {
|
||||
expect(contentAreaSource).toContain('<DataGridColumnLayoutPopover :grid="dataGridRef" :compact="compact" />');
|
||||
|
||||
const gridState = createGrid(4);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.url), "utf8");
|
||||
const etcdBrowserSource = readFileSync(new URL("../../etcd/EtcdKeyBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("KvKeyBrowser node export", () => {
|
||||
it("keeps directory toggles out of the detail-loading path", () => {
|
||||
expect(browserSource).toContain('if (node.kind === "lazy") return node.hasValue === true;');
|
||||
expect(browserSource).toContain("if (props.enableNodeActions && nodeHasValue(node)) void loadSelectedKey(routeFromNode(node));");
|
||||
expect(browserSource).not.toContain("function nodeMayHaveValue");
|
||||
});
|
||||
|
||||
it("keeps an expanded virtual Consul directory open when its exact Key probe is absent", () => {
|
||||
expect(browserSource).toContain("const lazyNode = lazyTreeState.nodeByKey.get(key);");
|
||||
expect(browserSource).toContain("if (lazyNode?.hasChildren)");
|
||||
expect(browserSource).toContain("lazyNode.hasValue = false;");
|
||||
expect(browserSource).toContain("void refreshLazyParent(parentLazyKvPath(key, props.lazyPathStyle));");
|
||||
});
|
||||
|
||||
it("shows export for both value keys and virtual directories", () => {
|
||||
expect(browserSource).toContain("if (props.api.exportScope || nodeHasValue(node))");
|
||||
expect(browserSource).toContain('kind: nodeIsExpandable(node) ? "prefix" : "key"');
|
||||
expect(browserSource).toContain("await props.api.exportScope(props.connectionId, request)");
|
||||
});
|
||||
|
||||
it("delegates etcd directory export to a fixed-revision recursive scan", () => {
|
||||
expect(etcdBrowserSource).toContain("exportScope: exportEtcdNodeScope");
|
||||
expect(etcdBrowserSource).toContain("const scan = await scanConnection(connectionId, request.path)");
|
||||
expect(etcdBrowserSource).toContain("isKeyInKvExportScope(displayKey(keyValue(entry)), request)");
|
||||
expect(etcdBrowserSource).toContain("const missingValue = entries.find((entry) => !entry.value)");
|
||||
});
|
||||
|
||||
it("compares canonical Key bytes without exposing mirror-delete operations", () => {
|
||||
expect(etcdBrowserSource).toContain("id: `source:${kvValueByteIdentity(source.key)}`");
|
||||
expect(etcdBrowserSource).not.toContain("id: source.key.data");
|
||||
expect(etcdBrowserSource).not.toContain("mirrorDeletes");
|
||||
expect(etcdBrowserSource).not.toContain('operation: "delete"');
|
||||
});
|
||||
|
||||
it("snapshots the target and invalidates stale transfer previews", () => {
|
||||
expect(etcdBrowserSource).toContain("const targetId = targetConnectionId.value");
|
||||
expect(etcdBrowserSource).toContain("const generation = ++transferPreviewGeneration");
|
||||
expect(etcdBrowserSource).toContain("if (generation !== transferPreviewGeneration) return");
|
||||
expect(etcdBrowserSource).toContain(':disabled="transferLoading || transferApplying"');
|
||||
});
|
||||
|
||||
it("refreshes a partial batch before allowing the remaining operations to retry", () => {
|
||||
expect(etcdBrowserSource).toContain('row.operation = "applied"');
|
||||
expect(etcdBrowserSource).toContain("await previewTransfer()");
|
||||
expect(etcdBrowserSource).toContain('t("etcd.transferPartiallyApplied"');
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("KvKeyBrowser search and split layout", () => {
|
||||
it("uses the requested prefix for a server-side, paginated search", () => {
|
||||
expect(browserSource).toContain("props.api.listPrefix(connectionId, searchQuery, pageSize, reset ? null : continuation.value");
|
||||
expect(browserSource).not.toContain("async function searchAllKeys");
|
||||
expect(browserSource).not.toContain("filterKvKeysBySearch(result.keys, query)");
|
||||
});
|
||||
|
||||
it("focuses a matching Consul directory instead of nesting it beneath itself", () => {
|
||||
expect(browserSource).toContain('const focusedDirectory = props.lazyPathStyle === "relative" && rootPath && !rootPath.endsWith("/")');
|
||||
expect(browserSource).toContain("key.key === `${rootPath}/`");
|
||||
expect(browserSource).toContain("const childResult = await props.api.listPrefix(context.connectionId, focusedDirectory.key, pageSize, null, { recursive: false });");
|
||||
expect(browserSource).toContain("if (rootSummary?.hasValue !== true)");
|
||||
expect(browserSource).toContain("hasValue: true");
|
||||
});
|
||||
|
||||
it("offers debounced, server-backed prefix completion for lazy Consul trees", () => {
|
||||
expect(browserSource).toContain("const remoteKeySuggestions = ref<KvKeySummary[]>([]);");
|
||||
expect(browserSource).toContain("const keySuggestionDebounceMs = 300;");
|
||||
expect(browserSource).toContain("function scheduleRemoteKeySuggestions(value: string)");
|
||||
expect(browserSource).toContain("listPrefix(connectionId, query, 8, null, { recursive: false })");
|
||||
expect(browserSource).toContain("const candidates = props.lazyHierarchy ? [...lazyTreeState.nodeByKey.values(), ...remoteKeySuggestions.value] : keys.value;");
|
||||
expect(browserSource).toContain('autocomplete="off"');
|
||||
expect(browserSource).toContain('<Popover :open="showKeySuggestions">');
|
||||
expect(browserSource).toContain('<PopoverContent v-if="showKeySuggestions" id="kv-key-prefix-suggestions"');
|
||||
expect(browserSource).toContain('if (props.lazyHierarchy && suggestion.key.endsWith("/"))');
|
||||
expect(browserSource).toContain("scheduleRemoteKeySuggestions(suggestion.key);");
|
||||
expect(browserSource).toContain('event.key !== "Enter" && event.key !== "Tab"');
|
||||
});
|
||||
|
||||
it("drops stale lazy tree responses after a connection or root generation changes", () => {
|
||||
expect(browserSource).toContain("generation: ++keyLoadGeneration");
|
||||
expect(browserSource).toContain("function lazyLoadContextValid(context: LazyLoadContext)");
|
||||
expect(browserSource).toContain("context.connectionId === props.connectionId");
|
||||
expect(browserSource).toContain("lazyTreeState.nodeByKey.get(parentKey) !== node");
|
||||
expect(browserSource).toContain("keyLoadGeneration++;");
|
||||
});
|
||||
|
||||
it("shows list request errors instead of the empty-state message", () => {
|
||||
expect(browserSource).toContain('const listError = ref("");');
|
||||
expect(browserSource.match(/listError\.value = formatError\(error\);/g)).toHaveLength(2);
|
||||
expect(browserSource.indexOf('v-else-if="listError"')).toBeLessThan(browserSource.indexOf('v-else-if="visibleRows.length === 0"'));
|
||||
});
|
||||
|
||||
it("highlights the matching search query only for the selected search result", () => {
|
||||
expect(browserSource).toContain("interface KvSearchHighlight");
|
||||
expect(browserSource).toMatch(/const activeSearchHighlight = computed\(\(\) => \(?props\.searchHighlight\?\.key === selectedKey\.value/);
|
||||
expect(browserSource).toContain("function highlightText(value: string, enabled: boolean)");
|
||||
expect(browserSource).toContain("selectedValueHighlightSegments");
|
||||
});
|
||||
|
||||
it("does not select Key text while opening its context menu", () => {
|
||||
expect(browserSource).toContain("select-none items-center");
|
||||
expect(browserSource).toContain("@mousedown.right.prevent");
|
||||
});
|
||||
|
||||
it("uses a persisted draggable split between the Key tree and details", () => {
|
||||
expect(browserSource).toContain('<Splitpanes class="kv-browser-splitpanes min-h-0 flex-1" @resized="handleKvBrowserSplitResized">');
|
||||
expect(browserSource).toContain('<Pane :size="kvBrowserSplitSize" min-size="20" max-size="70">');
|
||||
expect(browserSource).toContain('<Pane :size="100 - kvBrowserSplitSize" min-size="30">');
|
||||
expect(browserSource).toContain("safeLocalStorageSet(kvBrowserSplitSizeStorageKey, String(size))");
|
||||
expect(browserSource).toContain("cursor: col-resize");
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("KvKeyBrowser TTL details", () => {
|
||||
it("renders the selected key TTL and refreshes it every second", () => {
|
||||
expect(browserSource).toContain("const selectedTtlLabel = computed");
|
||||
expect(browserSource).toContain('<Badge v-if="selectedTtlLabel" variant="outline">TTL {{ selectedTtlLabel }}</Badge>');
|
||||
expect(browserSource).toContain("metadataRefreshTimer = setInterval");
|
||||
expect(browserSource).toContain("void refreshSelectedMetadata(key, generation)");
|
||||
});
|
||||
|
||||
it("stops TTL polling when the browser unmounts", () => {
|
||||
expect(browserSource).toContain("onBeforeUnmount(() =>");
|
||||
expect(browserSource).toContain("stopKeyListRefresh()");
|
||||
expect(browserSource).toContain("stopMetadataRefresh()");
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const tabBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8");
|
||||
const groupTabBarSource = readFileSync(new URL("../EditorGroupTabBar.vue", import.meta.url), "utf8");
|
||||
const groupSource = readFileSync(new URL("../EditorGroup.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("AppTabBar shared group navigation", () => {
|
||||
it("hosts existing group bars without rendering a second tab strip", () => {
|
||||
expect(tabBarSource).toContain("GROUP_TAB_BAR_PORTAL");
|
||||
expect(tabBarSource).toContain('v-for="group in queryStore.groups"');
|
||||
expect(tabBarSource).toContain(':data-special-page-tab-target="group.id"');
|
||||
expect(tabBarSource).not.toContain("overlayReturnTabs");
|
||||
expect(tabBarSource).not.toContain("data-return-tab");
|
||||
expect(tabBarSource).not.toContain("createRenameDuplicateTabItems");
|
||||
expect(groupSource).toContain("<Teleport defer");
|
||||
expect(groupSource).toContain("if (!tabBarPortal?.active.value) return undefined;");
|
||||
expect(groupSource).toContain(':disabled="!tabBarPortal?.active.value || !tabBarTarget"');
|
||||
});
|
||||
|
||||
it("retains targets while hiding inactive special content and follows all placements", () => {
|
||||
expect(tabBarSource).toContain('v-show="driverStoreActive || pluginCenterActive || settingsPageActive"');
|
||||
expect(tabBarSource).toContain("data-special-page-navigation");
|
||||
expect(tabBarSource).toContain("data-special-page-content");
|
||||
expect(tabBarSource).toContain("<slot />");
|
||||
for (const layout of ["flex-col", "flex-col-reverse", "flex-row", "flex-row-reverse"]) {
|
||||
expect(tabBarSource).toContain(layout);
|
||||
}
|
||||
expect(tabBarSource).toContain("props.tabBarCollapsed");
|
||||
expect(tabBarSource).toContain("props.tabBarWidth");
|
||||
});
|
||||
|
||||
it("scopes the close-other shortcut to the active tab's owner group", () => {
|
||||
expect(tabBarSource).toContain("closeOtherTabsInGroup(ownerGroup.id, activeTabId)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppTabBar close confirmation layout", () => {
|
||||
it("allows long unbroken tab titles to shrink and wrap inside the dialog", () => {
|
||||
expect(tabBarSource).toMatch(/<DialogContent class="[^"]*\bmin-w-0\b[^"]*\bsm:max-w-md\b/);
|
||||
expect(tabBarSource).toMatch(/<div class="[^"]*\bmin-w-0\b[^"]*\bspace-y-2\b">\s*<p class="[^"]*\bwrap-anywhere\b/);
|
||||
});
|
||||
|
||||
it("keeps all single and bulk close actions while allowing the footer to wrap", () => {
|
||||
expect(tabBarSource).toMatch(/<DialogFooter class="[^"]*\bmin-w-0\b[^"]*\bsm:flex-wrap\b">/);
|
||||
expect(tabBarSource).toContain('v-if="showCloseConfirmBulkActions" variant="secondary" class="border-border" @click="handleDiscardAllAndClose"');
|
||||
expect(tabBarSource).toContain('v-if="showCloseConfirmBulkActions" @click="handleSaveAllAndClose"');
|
||||
expect(tabBarSource).toContain('variant="secondary" class="border-border" @click="handleDiscardAndClose"');
|
||||
expect(tabBarSource).toContain('@click="handleSaveAndClose"');
|
||||
expect(tabBarSource).toContain('@click="handleCancelClose"');
|
||||
});
|
||||
|
||||
it("shows the dirty tab list using the shared tab title presentation", () => {
|
||||
const start = tabBarSource.indexOf('v-for="tab in closeConfirmDirtyTabs"');
|
||||
const end = tabBarSource.indexOf("</PopoverContent>", start);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
expect(tabBarSource.slice(start, end)).toContain("closeConfirmTabTitle(tab)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Group strip special page tabs", () => {
|
||||
it("keeps special page tabs and their update badge in the focused group", () => {
|
||||
expect(groupTabBarSource).toContain("queryStore.focusedGroupId === props.groupId");
|
||||
expect(groupTabBarSource).toContain("data-settings-page-tab");
|
||||
expect(groupTabBarSource).toContain("data-plugin-center-tab");
|
||||
expect(groupTabBarSource).toContain("data-driver-store-tab");
|
||||
expect(groupTabBarSource).toContain("@click=\"emit('activate-settings')\"");
|
||||
expect(groupTabBarSource).toContain("@click=\"emit('activate-plugin-center')\"");
|
||||
expect(groupTabBarSource).toContain("@click=\"emit('activate-driver-store')\"");
|
||||
expect(groupTabBarSource).toContain('t("toolbar.driverManager")');
|
||||
expect(groupTabBarSource).toContain("aria-label=\"t('toolbar.updatableDriverCount')\"");
|
||||
expect(groupTabBarSource).toContain("specialPageTabs?.driverUpdateCount");
|
||||
});
|
||||
|
||||
it("routes special-page actions and preserves ordinary-tab dirty closing", () => {
|
||||
expect(groupSource).toContain(':special-page-tabs="toolbar.specialPageTabs.value"');
|
||||
expect(groupSource).toContain('@activate-settings="toolbar.activateSettingsPage()"');
|
||||
expect(groupSource).toContain('@activate-plugin-center="toolbar.activatePluginCenter()"');
|
||||
expect(groupSource).toContain('@close-plugin-center="toolbar.closePluginCenter()"');
|
||||
expect(groupSource).toContain('@close-driver-store="toolbar.closeDriverStore()"');
|
||||
expect(groupTabBarSource).toContain('@mousedown.middle.prevent="closeTab(entry.tab)"');
|
||||
expect(groupTabBarSource).toContain('@click.stop="closeTab(entry.tab)"');
|
||||
expect(groupTabBarSource).toContain("queryStore.closeTab(tab.id)");
|
||||
});
|
||||
|
||||
it("uses one presentation path for active special pages and inactive ordinary tabs", () => {
|
||||
expect(groupTabBarSource).toContain("return !specialPageActive.value && tab.id === props.activeTabId;");
|
||||
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.settingsActive)");
|
||||
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.pluginCenterActive)");
|
||||
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.driverStoreActive)");
|
||||
expect(groupTabBarSource).toContain("if (!active) return undefined;");
|
||||
expect(groupTabBarSource).toContain('return { "--app-tab-background": activeBackground, boxShadow: "inset 0 -2px 0 color-mix(in srgb, var(--foreground) 72%, transparent)" };');
|
||||
expect(groupTabBarSource).toContain('import "./appTabBar.css"');
|
||||
expect(groupTabBarSource).toContain("dirty-tab-marker");
|
||||
expect(groupTabBarSource).toContain("dirtyTabTitleStyle");
|
||||
expect(groupTabBarSource).toContain("createRenameDuplicateTabItems");
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const contentAreaSource = readFileSync(new URL("../ContentArea.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ContentArea data toolbar", () => {
|
||||
it("keeps table properties in the primary toolbar at every overflow tier", () => {
|
||||
expect(contentAreaSource).toContain('v-if="activeTab.result && activeDataTabTableMeta && activeTab.connectionId"');
|
||||
expect(contentAreaSource).not.toContain("showDataTableInfoButton");
|
||||
expect(contentAreaSource).not.toContain("ToolbarOverflowMenu");
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const contentAreaSource = readFileSync(new URL("../ContentArea.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ContentArea completed query output", () => {
|
||||
it("re-evaluates the no-table fallback when tab activation resets the shared view", () => {
|
||||
expect(contentAreaSource).toContain("props.activeTab.isExecuting, props.activeOutputView] as const");
|
||||
expect(contentAreaSource).toContain('emit("update:activeOutputView", props.activeTab.id, result ? defaultViewForResult(result) : "summary")');
|
||||
});
|
||||
|
||||
it("scopes the output-view watcher to the shared result surface", () => {
|
||||
expect(contentAreaSource).toContain("if (props.editorOnly) return;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentArea editor-only split sizing", () => {
|
||||
it("keeps the editor pane at 100% in editor-only mode so no blank dead zone is left behind", () => {
|
||||
// The shared result surface renders results in its own workspace pane, so
|
||||
// this splitpanes has a single (editor) pane in editor-only mode. A purely
|
||||
// reactive size prop does not re-normalize a lone pane, so any value below
|
||||
// 100 would shrink the editor and leave the remainder as empty background.
|
||||
expect(contentAreaSource).toContain("const editorPaneSize = computed(() => (props.editorOnly || !resultsPaneOpen.value ? 100 : 100 - resultsPaneSize.value));");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentArea Mongo tab reuse", () => {
|
||||
it("remounts the document browser when the active tab changes collections", () => {
|
||||
expect(contentAreaSource).toContain(':key="`${activeTab.id}:${activeTab.sql}`"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentArea query result grid", () => {
|
||||
it("passes connection and per-run count SQL to the query-tab grid so the total can be re-counted", () => {
|
||||
const countSqlProp = ':count-sql="activeTab.resultCountSql"';
|
||||
const countSqlIndex = contentAreaSource.indexOf(countSqlProp);
|
||||
expect(countSqlIndex).toBeGreaterThan(-1);
|
||||
// Robust string-matching: anchor on the per-run count SQL and walk back to
|
||||
// the enclosing DataGrid so the test does not depend on DataGrid ordering
|
||||
// while staying in readFileSync format.
|
||||
const dataGridIndex = contentAreaSource.lastIndexOf("<DataGrid", countSqlIndex);
|
||||
expect(dataGridIndex).toBeGreaterThan(-1);
|
||||
const queryGridBlock = contentAreaSource.slice(dataGridIndex, countSqlIndex + 900);
|
||||
expect(queryGridBlock).toContain("<DataGrid");
|
||||
expect(queryGridBlock).toContain(':connection-id="activeResultConnectionId"');
|
||||
expect(queryGridBlock).toContain(':count-sql="activeTab.resultCountSql"');
|
||||
expect(queryGridBlock).toContain(':count-total-rows="activeTab.resultCountSql ? () => queryStore.countTabResultRows(activeTab.id) : undefined"');
|
||||
expect(queryGridBlock).toContain(':total-row-count="activeTab.resultTotalRowCount"');
|
||||
expect(queryGridBlock).toContain(':total-row-count-loading="activeTab.resultTotalRowCountLoading"');
|
||||
});
|
||||
});
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function read(relativePath: string) {
|
||||
return readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
// 能打开「导入表数据」向导的三个入口。侧边栏的守卫在菜单宿主(SidebarTreeRuntimeHost.vue)里,
|
||||
// 写入函数 composables/useSidebarTreeToolRuntime.ts 本身不带守卫,所以这里列的是入口界面而不是写入点。
|
||||
const TABLE_IMPORT_ENTRY_SURFACES = ["../ContentArea.vue", "../../objects/ObjectBrowser.vue", "../../sidebar/SidebarTreeRuntimeHost.vue"];
|
||||
|
||||
describe("ContentArea table import entry", () => {
|
||||
it("gates the data-tab toolbox import item on the shared capability check", () => {
|
||||
const source = read("../ContentArea.vue");
|
||||
// HANA 的能力清单是 tableImport:false。入口不跟能力走时,向导仍能打开并停在「选项」步:
|
||||
// 目标表字段元数据取不回来,下一步恒为禁用(QA #hana-import 的复现路径)。
|
||||
expect(source).toContain("const canOpenTableImport = computed(");
|
||||
expect(source).toContain("supportsTableImport(activeEffectiveDatabaseType.value)");
|
||||
|
||||
const item = source.indexOf('t("tableToolbox.importData")');
|
||||
expect(item).toBeGreaterThan(-1);
|
||||
const openTag = source.lastIndexOf("<DropdownMenuItem", item);
|
||||
expect(openTag).toBeGreaterThan(-1);
|
||||
expect(source.slice(openTag, item)).toContain('v-if="canOpenTableImport"');
|
||||
});
|
||||
|
||||
it("keeps every entry surface consulting the same capability", () => {
|
||||
for (const surface of TABLE_IMPORT_ENTRY_SURFACES) {
|
||||
expect(read(surface), surface).toContain("supportsTableImport");
|
||||
}
|
||||
});
|
||||
});
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const contentAreaSource = readFileSync(new URL("../ContentArea.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ContentArea restored data-tab refresh", () => {
|
||||
it("routes both no-data refresh entries through the filter-preserving helper (#7963)", () => {
|
||||
// 裸 emit("reload") 会让 onReloadData 把缺省的 whereInput/orderBy 当成
|
||||
// “用户已清空”,刷新后退回整张表的数据。
|
||||
expect(contentAreaSource).not.toContain(`@click="emit('reload')"`);
|
||||
expect(contentAreaSource).not.toMatch(/\bemit\("reload"\)\s*;/);
|
||||
expect(contentAreaSource).toContain(`@click="reloadUnavailableDataTab()"`);
|
||||
expect(contentAreaSource).toContain("reloadUnavailableDataTab();");
|
||||
});
|
||||
|
||||
it("passes the restored tab's own WHERE/ORDER BY into the reload event", () => {
|
||||
expect(contentAreaSource).toContain("const { whereInput, orderBy } = restoredDataTabReloadFilters(props.activeTab);");
|
||||
// The tabId-first contract still reserves the sql and searchText slots before
|
||||
// the restored filters. Omitting either placeholder shifts ORDER BY into WHERE.
|
||||
expect(contentAreaSource).toContain(`emit("reload", props.activeTab.id, undefined, undefined, whereInput, orderBy);`);
|
||||
});
|
||||
});
|
||||
-243
@@ -35,245 +35,8 @@ vi.mock("@/lib/backend/api", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
const specDir = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(resolve(specDir, "../EditorGroupTabBar.vue"), "utf8");
|
||||
const sharedStyles = readFileSync(resolve(specDir, "../appTabBar.css"), "utf8");
|
||||
|
||||
describe("EditorGroupTabBar semantic tab groups", () => {
|
||||
it("keeps separate collapsed state for pinned and regular clusters under the same key", () => {
|
||||
expect(source).toContain("const collapsedTabGroups = ref<Set<string>>(new Set());");
|
||||
expect(source).toContain('return `${tab.pinned ? "fixed" : "regular"}:${settingsStore.editorSettings.tabGroupMode}:${tabGroupKey(tab)}`;');
|
||||
expect(source).toContain("function toggleTabGroup(tab: QueryTab)");
|
||||
expect(source).toContain(':aria-expanded="!isTabGroupCollapsed(entry.tab)"');
|
||||
});
|
||||
|
||||
it("counts group entries in linear time instead of rescanning the section", () => {
|
||||
const builder = sourceBetween("function buildStripEntries", "const pinnedStripEntries");
|
||||
expect(builder).toContain("const groupKeys = grouping ? section.map(tabGroupKey) : [];");
|
||||
expect(builder).toContain("const groupCounts = new Map<string, number>();");
|
||||
expect(builder).toContain("groupCounts.get(groupKey!) ?? 0");
|
||||
expect(builder).not.toContain("section.filter(");
|
||||
});
|
||||
|
||||
it("computes the active group once instead of scanning every group", () => {
|
||||
const activeGroup = sourceBetween("const activeTabGroupId", "function isTabGroupActive");
|
||||
expect(activeGroup).toContain("const activeTab = props.tabs.find((item) => isTabActive(item));");
|
||||
expect(source).toContain("return activeTabGroupId.value === tabGroupId(tab);");
|
||||
});
|
||||
|
||||
it("expands a collapsed group when one of its tabs becomes active", () => {
|
||||
expect(source).toContain("expandTabGroupForTab(tabId);");
|
||||
});
|
||||
|
||||
it("supports persistent names and colors from the group header context menu", () => {
|
||||
expect(source).toContain('["#2563eb", "#d97706", "#7c3aed", "#059669", "#dc2626", "#0891b2", "#db2777", "#475569"]');
|
||||
expect(source).toContain("tabGroupCustomizations: customizations");
|
||||
expect(source).toContain("data-tab-group-name-input");
|
||||
expect(source).toContain('type="color"');
|
||||
});
|
||||
|
||||
it("keeps placement, grouping, and sorting out of the group context menu", () => {
|
||||
const groupMenu = sourceBetween("function getTabGroupMenuItems", "function openTabGroupContextMenu");
|
||||
expect(groupMenu).not.toContain("getTabPreferenceMenuItems");
|
||||
});
|
||||
|
||||
it("exposes preferences and the group close from each tab context menu", () => {
|
||||
const menuStart = source.indexOf("function getTabMenuItems");
|
||||
const menuEnd = source.indexOf("function handleTabDoubleClick");
|
||||
expect(menuStart).toBeGreaterThanOrEqual(0);
|
||||
expect(menuEnd).toBeGreaterThan(menuStart);
|
||||
const menu = source.slice(menuStart, menuEnd);
|
||||
expect(menu).not.toContain("getTabPreferenceMenuItems");
|
||||
expect(menu).toContain("action: () => closeTabGroup(tab)");
|
||||
expect(menu).toContain('visible: settingsStore.editorSettings.tabGroupMode !== "none"');
|
||||
});
|
||||
|
||||
it("closes the global semantic group by key, not just this pane's cluster", () => {
|
||||
// D1 (amended): closing a cluster is destructive and stays bar-local —
|
||||
// scoped to this pane's tabs (props.tabs), still within the trigger's
|
||||
// pinned section. Profile edits (rename/color/reset) keep global reach.
|
||||
const closeGroup = sourceBetween("function tabsInSemanticGroup", "function getTabGroupMenuItems");
|
||||
expect(closeGroup).toContain("props.tabs.filter((item) => item.pinned === tab.pinned && tabGroupKey(item) === groupKey)");
|
||||
expect(closeGroup).toContain("queryStore.closeTabsByIds(tabsToClose, finalActiveTabId)");
|
||||
});
|
||||
|
||||
it("waits for tab preference persistence and treats the whole group title as the context target", () => {
|
||||
expect(source).toContain("await settingsStore.updateEditorSettingsAndPersist(partial)");
|
||||
expect(source).toContain("openTabGroupContextMenu($event, onContextMenu)");
|
||||
expect(source).toContain("document.getSelection()?.removeAllRanges()");
|
||||
});
|
||||
|
||||
it("applies the group rail classes to clustered pills", () => {
|
||||
// Grouped pills carry tab-group-tab for the entry underline and the
|
||||
// vertical rail, with first/last markers available for side layouts.
|
||||
expect(source).toContain("'tab-group-tab': entry.grouping,");
|
||||
expect(source).toContain("'tab-group-tab--first': entry.grouping && entry.groupFirst,");
|
||||
expect(source).toContain("'tab-group-tab--last': entry.grouping && entry.groupLast,");
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\) \.tab-group-tab::after\s*\{[^}]*bottom:\s*-0\.5px;/s);
|
||||
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--last::after");
|
||||
expect(sharedStyles).toMatch(/\.tab-group-tab\[data-active-tab="true"\]::after,[\s\S]*?\.tab-group-entry:has\(\.tab-group-tab\[data-active-tab="true"\]\)::after\s*\{[^}]*z-index:\s*2;[^}]*var\(--foreground\) 72%/s);
|
||||
});
|
||||
|
||||
it("hides collapsed cluster pills but reveals them while searching", () => {
|
||||
expect(source).toContain("grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab)");
|
||||
expect(sharedStyles).toContain(".tab-group-entry--collapsed");
|
||||
});
|
||||
|
||||
it("remeasures overflow after a single-row group expansion finishes", () => {
|
||||
expect(source).toContain('@transitionend.self="handleTabGroupTransitionEnd"');
|
||||
expect(source).toContain(':data-tab-group-id="entry.grouping ? tabGroupId(entry.tab) : undefined"');
|
||||
expect(source).toContain("captureExpandedTabGroupWidths(new Set(pending))");
|
||||
expect(source).toContain("'tab-group-entry--collapsing'");
|
||||
expect(source).toContain("window.setTimeout(() =>");
|
||||
expect(sharedStyles).toContain("@keyframes tab-group-collapse");
|
||||
expect(sharedStyles).toMatch(/\.tab-group-entry--collapsing\s*\{[^}]*animation:\s*tab-group-collapse 140ms ease forwards;/s);
|
||||
expect(source).toContain('entry.style.removeProperty("--tab-group-entry-expanded-width")');
|
||||
expect(sharedStyles).toContain("max-width: var(--tab-group-entry-expanded-width, 100%)");
|
||||
expect(sharedStyles).toMatch(/\.tab-group-entry\[data-tab-group-id\] > \.tab-group-tab\s*\{[^}]*width:\s*var\(--tab-group-entry-expanded-width, auto\);[^}]*min-width:\s*var\(--tab-group-entry-expanded-width, max-content\) !important;[^}]*flex:\s*none;/s);
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\) \.tab-group-entry\s*\{[^}]*min-width:\s*0;/s);
|
||||
const handler = sourceBetween("function handleTabGroupTransitionEnd", "function toggleTabGroup");
|
||||
expect(handler).toContain('event.propertyName !== "max-width"');
|
||||
expect(handler).toContain("refreshHorizontalTabOverflow();");
|
||||
expect(source).toContain('return "tab-tail-overflow-spacer flex-none self-stretch";');
|
||||
expect(sharedStyles).toContain(".tab-tail-overflow-spacer[data-tauri-drag-region]");
|
||||
});
|
||||
|
||||
it("keeps the overflow search scoped to the popover list without filtering the strip", () => {
|
||||
// Regression: the popover's "search opened tabs" box shared the strip's
|
||||
// query, so typing a term with no match emptied the top tab bar while the
|
||||
// active tab's content stayed on screen.
|
||||
const overflowFilter = sourceBetween("const filteredGroupTabs", "watch(tabOverflowOpen");
|
||||
expect(overflowFilter).toContain("tabOverflowSearchQuery.value.trim()");
|
||||
expect(overflowFilter).not.toContain("tabSearchQuery");
|
||||
const overflowOpenWatch = sourceBetween("watch(tabOverflowOpen", "const showOverflowControl");
|
||||
expect(overflowOpenWatch).toContain('tabOverflowSearchQuery.value = "";');
|
||||
const stripFilter = sourceBetween("const filteredPinnedTabs", "function buildStripEntries");
|
||||
expect(stripFilter).toContain("tabSearchQuery.value.trim()");
|
||||
expect(stripFilter).not.toContain("tabOverflowSearchQuery");
|
||||
expect(source).toContain('<Input v-model="tabOverflowSearchQuery" data-group-tab-search-input');
|
||||
});
|
||||
|
||||
it("keeps wrap layout out of the vertical placement", () => {
|
||||
expect(source).toContain('const isWrapLayout = computed(() => !isVerticalLayout.value && settingsStore.editorSettings.tabLayout === "wrap");');
|
||||
});
|
||||
|
||||
it("uses compact group pills and places the accent next to content for horizontal bars", () => {
|
||||
expect(source).toContain('isClassicLayout.value ? "classic-tab-layout" : "separated-tab-layout"');
|
||||
expect(source).toContain(':data-placement="settingsStore.editorSettings.tabPlacement"');
|
||||
expect(source).toContain(':data-group-mode="settingsStore.editorSettings.tabGroupMode"');
|
||||
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header");
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar\.classic-tab-layout:not\(\.vertical-tab-layout\) \.tab-group-header-content\s*\{[^}]*height:\s*100%;[^}]*border-radius:\s*0;/s);
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar\.classic-tab-layout:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\)\[data-group-mode="none"\] \.app-tab-pill\s*\{[^}]*border-right-width:\s*0\.5px;/s);
|
||||
expect(sharedStyles).toMatch(
|
||||
/\.app-tab-bar\.classic-tab-layout:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\)\[data-placement="top"\]\[data-group-mode="none"\] \.app-tab-pill\[data-active-tab="true"\]\s*\{[^}]*border-top-color:\s*transparent;[^}]*border-right-color:\s*transparent;[^}]*border-left-color:\s*transparent;/s,
|
||||
);
|
||||
expect(sharedStyles).toMatch(/\.tab-overflow-control\s*\{[^}]*width:\s*34px;[^}]*flex:\s*0 0 34px;/s);
|
||||
expect(sharedStyles).toContain("padding-inline-end: 2.125rem;");
|
||||
expect(sharedStyles).toContain("inset-inline-end: 2.125rem;");
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\)\[data-placement="top"\] \.tab-overflow-control::before\s*\{[^}]*inset-inline:\s*-0\.25rem 0;[^}]*background:\s*transparent;/s);
|
||||
expect(sharedStyles).toMatch(/\.app-tab-scroll\.wrap-mode\.classic-wrap \.tab-group-header,[\s\S]*?\.tab-group-header-content\s*\{[^}]*height:\s*2rem !important;/s);
|
||||
expect(sharedStyles).toMatch(/\.app-tab-scroll\.wrap-mode\.classic-wrap \.tab-group-header:not\(\.tab-group-header--collapsed\)::after\s*\{[^}]*right:\s*-1px;/s);
|
||||
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header::after");
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\) \.tab-group-header::after\s*\{[^}]*bottom:\s*0;/s);
|
||||
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header--collapsed::after");
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):has\(\.wrap-mode\) \.tab-group-tab::after\s*\{[^}]*bottom:\s*-0\.5px;[^}]*background:\s*var\(--tab-group-color\);/s);
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):has\(\.wrap-mode\)\[data-placement="bottom"\] \.tab-group-tab::after\s*\{[^}]*top:\s*-0\.5px;[^}]*bottom:\s*auto;/s);
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\)\[data-placement="bottom"\] \.tab-group-tab::after\s*\{[^}]*top:\s*-0\.5px;/s);
|
||||
expect(sharedStyles).toContain(".app-tab-scroll.wrap-mode.classic-wrap .tab-section--horizontal > .app-tab-pill");
|
||||
expect(sharedStyles).toContain(".app-tab-scroll.wrap-mode:not(.classic-wrap) .tab-section--horizontal > .app-tab-pill");
|
||||
expect(sharedStyles).toContain("row-gap: 0.375rem;");
|
||||
expect(sharedStyles).toContain(".app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry:has(.tab-group-tab)");
|
||||
expect(sharedStyles).toContain('[data-group-mode="none"] .tab-section--horizontal');
|
||||
expect(sharedStyles).toContain("column-gap: 4px;");
|
||||
expect(sharedStyles).toContain(".app-tab-bar.separated-tab-layout.horizontal-fixed-tabs .app-tab-scroll:not(.wrap-mode)");
|
||||
expect(sharedStyles).toContain(".horizontal-fixed-tabs-scroll.wrap-mode");
|
||||
expect(sharedStyles).toContain("row-gap: 0.375rem !important;");
|
||||
expect(sharedStyles).toMatch(/\.horizontal-fixed-tabs-scroll\.wrap-mode\.classic-wrap\s*\{[^}]*row-gap:\s*0\.25rem !important;/s);
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar\.classic-tab-layout:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\) \.tab-group-entry\s*\{[^}]*height:\s*100%;[^}]*max-height:\s*none;/s);
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):has\(\.wrap-mode\) \.tab-group-entry:has\(\.tab-group-tab\)::after\s*\{[^}]*bottom:\s*0;/s);
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar\.classic-tab-layout:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\)\[data-placement="top"\] \.app-tab-pill\s*\{[^}]*border-top-width:\s*0;/s);
|
||||
expect(sharedStyles).toContain("scroll-margin-inline-end: 1px;");
|
||||
});
|
||||
|
||||
function sourceBetween(start: string, end: string): string {
|
||||
const startIndex = source.indexOf(start);
|
||||
const endIndex = source.indexOf(end, startIndex + start.length);
|
||||
expect(startIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(endIndex).toBeGreaterThan(startIndex);
|
||||
return source.slice(startIndex, endIndex);
|
||||
}
|
||||
});
|
||||
|
||||
describe("EditorGroupTabBar vertical placement", () => {
|
||||
const groupSource = readFileSync(resolve(specDir, "../EditorGroup.vue"), "utf8");
|
||||
const workspaceSource = readFileSync(resolve(specDir, "../SqlEditorWorkspace.vue"), "utf8");
|
||||
const appSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../../../App.vue"), "utf8");
|
||||
|
||||
it("renders each pane's own bar as a vertical strip when placement is left/right", () => {
|
||||
expect(source).toContain('const isVerticalLayout = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right");');
|
||||
expect(source).toContain('isVerticalLayout.value && props.tabBarCollapsed ? "vertical-tab-layout--collapsed" : ""');
|
||||
// The strip flips to a column with vertical scrolling; no horizontal strip remains.
|
||||
expect(source).toContain("isVerticalLayout ? 'flex-col items-stretch overflow-y-auto overflow-x-hidden py-1'");
|
||||
});
|
||||
|
||||
it("keeps vertical rows fixed and aligns the sidebar toolbar with content headers", () => {
|
||||
expect(sharedStyles).toMatch(/\.vertical-tab-layout\s*\{[^}]*--tab-group-root-rail-x:\s*1\.16rem;[^}]*--tab-group-tab-inset:\s*1\.8rem;[^}]*--tab-group-branch-width:\s*0\.5rem;/s);
|
||||
expect(sharedStyles).toContain("width: calc(100% - var(--tab-group-tab-inset) - 0.25rem);");
|
||||
expect(sharedStyles).toContain("margin-inline: var(--tab-group-tab-inset) 0.25rem;");
|
||||
expect(sharedStyles).toContain("padding-inline: 0.2rem 0.25rem !important;");
|
||||
expect(sharedStyles).toMatch(/\.vertical-tab-layout \.tab-group-entry\s*\{[^}]*flex:\s*none;/s);
|
||||
expect(sharedStyles).toMatch(/\.vertical-tab-layout \.app-tab-scroll\s*\{[^}]*overflow-x:\s*hidden;/s);
|
||||
expect(sharedStyles).toMatch(/\.vertical-tab-layout \.app-tab-pill\s*\{[^}]*border-radius:\s*var\(--dbx-radius-md\);/s);
|
||||
expect(sharedStyles).toMatch(/\.vertical-tab-layout \.tab-group-entry\s*\{[^}]*max-height:\s*2rem;/s);
|
||||
expect(sharedStyles).toContain("max-height 140ms ease,");
|
||||
expect(sharedStyles).toMatch(/\.vertical-tab-layout \.tab-group-entry--collapsed\s*\{[^}]*max-height:\s*0;[^}]*opacity:\s*0;/s);
|
||||
expect(sharedStyles).toMatch(/\.vertical-tab-layout--collapsed \.tab-group-entry\s*\{[^}]*max-height:\s*2\.5rem;[^}]*overflow:\s*visible;/s);
|
||||
expect(sharedStyles).toMatch(/\.vertical-tab-layout--collapsed \.tab-group-entry--collapsed\s*\{[^}]*max-height:\s*0;[^}]*overflow:\s*hidden;/s);
|
||||
expect(source).toContain("if (isWrapLayout.value || isVerticalLayout.value) return;");
|
||||
expect(source).toContain("if (isWrapLayout.value) {");
|
||||
expect(source).toContain('inline: isVerticalLayout.value ? "nearest" : "center"');
|
||||
expect(source).toContain('class="flex h-9 shrink-0 items-center gap-0.5 border-b p-1"');
|
||||
expect(source).toContain('class="h-7 w-full pl-7 text-sm"');
|
||||
});
|
||||
|
||||
it("positions each pane's bar by the global placement without mixing directions", () => {
|
||||
expect(groupSource).toContain('case "bottom":');
|
||||
expect(groupSource).toContain('return "flex-col-reverse";');
|
||||
expect(groupSource).toContain('case "left":');
|
||||
expect(groupSource).toContain('return "flex-row";');
|
||||
expect(groupSource).toContain('case "right":');
|
||||
expect(groupSource).toContain('return "flex-row-reverse";');
|
||||
// The editor toolbar stays atop the pane's content column in every placement.
|
||||
expect(groupSource).toContain('class="flex min-h-0 min-w-0 flex-1 flex-col"');
|
||||
});
|
||||
|
||||
it("shares the vertical width/collapse state from App through the pane chain", () => {
|
||||
expect(appSource).toContain(':tab-bar-width="tabBarWidth"');
|
||||
expect(appSource).toContain(':tab-bar-collapsed="tabBarCollapsed"');
|
||||
expect(appSource).toContain('@start-resize="startTabBarResize"');
|
||||
expect(appSource).toContain('@toggle-collapse="toggleTabBarCollapsed"');
|
||||
expect(workspaceSource).toContain(':tab-bar-width="tabBarWidth"');
|
||||
expect(workspaceSource).toContain("@start-resize=\"emit('start-resize', $event)\"");
|
||||
expect(groupSource).toContain(':tab-bar-width="tabBarWidth"');
|
||||
// Dragging any pane's handle drives the shared resize handler.
|
||||
expect(source).toContain("@pointerdown=\"emit('start-resize', $event)\"");
|
||||
expect(source).toContain("@click=\"emit('toggle-collapse')\"");
|
||||
});
|
||||
|
||||
it("keeps horizontal placements immune to the persisted collapse state", () => {
|
||||
expect(source).toContain("const isTabBarCollapsed = computed(() => isVerticalLayout.value && !!props.tabBarCollapsed);");
|
||||
expect(source).toContain("if (!isVerticalLayout.value) return undefined;");
|
||||
});
|
||||
|
||||
it("uses the sidebar rail and soft active shadow for vertical pills", () => {
|
||||
expect(sharedStyles).toContain(".vertical-tab-layout .tab-group-tab::before");
|
||||
expect(sharedStyles).toMatch(/\.vertical-tab-layout \.tab-group-tab::before\s*\{[^}]*top:\s*-0\.25rem;[^}]*bottom:\s*-0\.25rem;/s);
|
||||
expect(sharedStyles).toContain('.vertical-tab-layout .app-tab-pill[data-active-tab="true"]');
|
||||
expect(sharedStyles).toContain("inset 0 0 0 1px color-mix");
|
||||
expect(sharedStyles).toContain(".vertical-tab-layout .tab-group-header:not(.tab-group-header--collapsed)::after");
|
||||
expect(sharedStyles).toContain("margin-inline: var(--tab-group-tab-inset) 0.25rem;");
|
||||
});
|
||||
});
|
||||
|
||||
function createHost(): HTMLDivElement {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
@@ -738,12 +501,6 @@ describe("EditorGroupTabBar group behavior", () => {
|
||||
const pill = host.querySelector<HTMLElement>(`[data-tab-id="${pluginTabId}"]`);
|
||||
expect(pill).toBeTruthy();
|
||||
expect(pill!.querySelector("img")).toBeTruthy();
|
||||
// The pill and the overflow popup list both render via TabModeIcon, whose
|
||||
// per-mode chain must keep the plugin-workbench plugin icon branch.
|
||||
const tabModeIconSource = readFileSync(resolve(specDir, "../TabModeIcon.vue"), "utf8");
|
||||
expect(tabModeIconSource).toContain("tab.mode === 'plugin-workbench' && tab.pluginWorkbench");
|
||||
expect(tabModeIconSource).toContain(':contribution-id="tab.pluginWorkbench.contributionId"');
|
||||
expect(source).toContain('<TabModeIcon :tab="entry.tab"');
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../EditorGroupTabBar.vue", import.meta.url), "utf8");
|
||||
const tabMenuSource = readFileSync(new URL("../../../lib/tabs/tabMenu.ts", import.meta.url), "utf8");
|
||||
|
||||
describe("EditorGroupTabBar compatibility with AppTabBar", () => {
|
||||
it("keeps data-tab double-click Zen mode behavior", () => {
|
||||
expect(source).toContain('"toggle-zen-mode": []');
|
||||
expect(source).toContain('tab.mode === "data"');
|
||||
expect(source).toContain('emit("toggle-zen-mode")');
|
||||
expect(source).toContain("if (tab.id !== props.activeTabId) {");
|
||||
expect(source).toContain("startRenameTab(tab);");
|
||||
});
|
||||
|
||||
it("only offers Duplicate for query tabs", () => {
|
||||
expect(tabMenuSource).toContain("visible: options.canRename");
|
||||
expect(source).toContain("createRenameDuplicateTabItems");
|
||||
});
|
||||
|
||||
it("does not double-activate from the tab item click", () => {
|
||||
expect(source).toContain("function activateTab(tabId: string) {");
|
||||
expect(source).toContain('emit("activate-tab", tabId);');
|
||||
expect(source).not.toContain("queryStore.activateTabInGroup(props.groupId, tabId);");
|
||||
});
|
||||
|
||||
it("cleans up drag listeners on cancel, blur, and unmount", () => {
|
||||
expect(source).toContain("function cleanupTabDrag(event?: Event)");
|
||||
expect(source).toContain('window.addEventListener("pointercancel", cleanupTabDrag)');
|
||||
expect(source).toContain('window.addEventListener("blur", cleanupTabDrag)');
|
||||
expect(source).toContain("onUnmounted(() => {");
|
||||
expect(source).toContain("cleanupTabDrag();");
|
||||
});
|
||||
|
||||
it("validates drag source and target groups before moving", () => {
|
||||
expect(source).toContain("serializeTabDragPayload");
|
||||
expect(source).toContain("parseTabDragPayload");
|
||||
expect(source).toContain("const sourceGroupExists = queryStore.groups.some((group) => group.id === payload.sourceGroupId)");
|
||||
expect(source).toContain("const targetGroupExists = queryStore.groups.some((group) => group.id === targetGroupId)");
|
||||
expect(source).toContain("if (!sourceGroupExists || !targetGroupExists) {");
|
||||
});
|
||||
|
||||
it("keeps pinned tabs in a separate row for horizontal placements", () => {
|
||||
expect(source).toContain('hasHorizontalFixedRows.value ? "horizontal-fixed-tabs" : ""');
|
||||
expect(source).toContain("horizontal-fixed-tabs-scroll");
|
||||
expect(source).toContain("const stripSections = computed(() =>");
|
||||
expect(source).toContain('{ key: "regular", pinned: false, entries: regularStripEntries.value }');
|
||||
expect(source).toContain('{ key: "fixed", pinned: true, entries: pinnedStripEntries.value }');
|
||||
expect(source).toContain("!isVerticalLayout.value && props.tabs.some((tab) => tab.pinned)");
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const panelSource = readFileSync(new URL("../SqlFilePanel.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("SqlFilePanel selection", () => {
|
||||
it("uses the shared ordered-list selection behavior", () => {
|
||||
expect(panelSource).toContain("orderedListSelectionIntent(event)");
|
||||
expect(panelSource).toContain("orderedListRangeAnchorIndex(visibleItems.value");
|
||||
expect(panelSource).not.toContain("anchorIndex ?? 0");
|
||||
});
|
||||
|
||||
it("does not open or expand rows during modifier selection", () => {
|
||||
expect(panelSource).toMatch(/if \(selectionIntent === "range"\)[\s\S]*selectRangeTo\(currentIndex\);[\s\S]*return;/);
|
||||
expect(panelSource).toMatch(/if \(selectionIntent === "toggle"\)[\s\S]*selectionAnchorIndex\.value = currentIndex;[\s\S]*return;/);
|
||||
expect(panelSource).toMatch(/selectionAnchorIndex\.value = currentIndex;[\s\S]*activate\(\);/);
|
||||
});
|
||||
|
||||
it("clears selection when a non-row area is clicked", () => {
|
||||
expect(panelSource).toContain('@click="handlePanelClick"');
|
||||
expect(panelSource).toContain("[data-sql-file-row='true']");
|
||||
expect(panelSource).toContain('data-sql-file-row="true"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqlFilePanel folder headers", () => {
|
||||
it("keeps sticky folder headers opaque while the file list scrolls", () => {
|
||||
expect(panelSource).toContain("bg-[color-mix(in_oklab,var(--muted)_10%,var(--background))] sticky top-0");
|
||||
expect(panelSource).toContain("hover:bg-[color-mix(in_oklab,var(--accent)_40%,var(--background))]");
|
||||
expect(panelSource).not.toContain("bg-muted/10 sticky top-0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqlFilePanel file renaming", () => {
|
||||
it("does not re-read a successfully renamed file before refreshing the tree", () => {
|
||||
const renameFunction = panelSource.match(/async function renameSqlFile\(\) \{[\s\S]*?\n\}/)?.[0];
|
||||
|
||||
expect(renameFunction).toBeDefined();
|
||||
expect(renameFunction).toContain("queryStore.relocateExternalSqlFilePath(target.entry.path, nextPath)");
|
||||
expect(renameFunction).not.toContain("readExternalSqlFileSnapshot(nextPath)");
|
||||
});
|
||||
|
||||
it("preserves non-SQL extensions while keeping SQL rename convenience", () => {
|
||||
expect(panelSource).toContain("normalizedRenamedFileName(fileNameInput.value, target.entry.name)");
|
||||
expect(panelSource).toContain("isSqlFilePath(currentName) ? normalizedSqlFileName(trimmed) : trimmed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqlFilePanel directory actions", () => {
|
||||
it("shows a new SQL file icon for nested directories", () => {
|
||||
expect(panelSource).toContain('@click.stop="openCreateDialog(folder.path, entry.path)"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqlFilePanel file filter", () => {
|
||||
it("restores the previous filter when the backend rejects the saved pattern", () => {
|
||||
expect(panelSource).toContain('message?.startsWith("Invalid file filter")');
|
||||
expect(panelSource).toContain("saveSqlFileFilter(previousFilter)");
|
||||
expect(panelSource).toContain('t("sqlFileTree.filterInvalid"');
|
||||
});
|
||||
|
||||
it("renders the translated filter placeholder", () => {
|
||||
expect(panelSource).toContain("t('sqlFileTree.fileFilterPlaceholder')");
|
||||
expect(panelSource).not.toContain("const fileFilterPlaceholder =");
|
||||
});
|
||||
|
||||
it("offers SQL execution only for SQL files", () => {
|
||||
expect(panelSource).toContain("if (isSqlFilePath(target.entry.name))");
|
||||
expect(panelSource).toContain("isExternalSqlFileTooLargeError(e) && isSqlFilePath(path)");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, h } from "vue";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ToolbarUpdateIcon from "../ToolbarUpdateIcon.vue";
|
||||
describe("silent toolbar update icon", () => {
|
||||
@@ -13,29 +11,4 @@ describe("silent toolbar update icon", () => {
|
||||
expect(container.querySelector("[data-toolbar-update-progress], [data-toolbar-update-scan]")).toBeNull();
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("renders a text Update action whenever any aggregate update is available", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/layout/AppToolbar.vue"), "utf8");
|
||||
|
||||
expect(source).toContain('v-if="hasUpdateAvailable"');
|
||||
expect(source).toContain("data-toolbar-update-action");
|
||||
expect(source).toContain('t("updates.updateAction")');
|
||||
});
|
||||
|
||||
it("keeps the update entry visible when the appearance setting is off but an update is available", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/layout/AppToolbar.vue"), "utf8");
|
||||
|
||||
expect(source).toContain("const showToolbarUpdateEntry = computed(() => toolbarItems.value.checkUpdates || props.hasUpdateAvailable);");
|
||||
expect(source).toContain("if (showToolbarUpdateEntry.value) {");
|
||||
expect(source).toContain('<template v-if="showToolbarUpdateEntry">');
|
||||
expect(source).not.toContain('<template v-if="toolbarItems.checkUpdates">');
|
||||
});
|
||||
|
||||
it("passes only opt-out component badges to their separate toolbar entries", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/App.vue"), "utf8");
|
||||
|
||||
expect(source).toContain(':agent-driver-update-count="showDriverStoreUpdateBadge"');
|
||||
expect(source).toContain(':has-mcp-update-available="showMcpSettingsUpdateBadge"');
|
||||
expect(source).not.toContain(':agent-driver-update-count="toolbarDriverUpdateCount"');
|
||||
});
|
||||
});
|
||||
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
|
||||
|
||||
// The editor-content wrapper carries a v-show guard that hides it while the
|
||||
// settings page or driver store is the active surface. A past merge resolved
|
||||
// this region by nesting the special surfaces INSIDE that guarded wrapper,
|
||||
// which display:none-d the settings page exactly when it was active. These
|
||||
// assertions pin the sibling order so a future merge cannot re-introduce it.
|
||||
describe("App main content surface structure", () => {
|
||||
it("hides only the editor workspace via the surface guard, never the special pages", () => {
|
||||
const guard = 'v-show="!driverStoreActive && !pluginCenterActive && !settingsStore.settingsPageActive"';
|
||||
// The empty-state slot shares the query surface's visibility guard.
|
||||
expect(appSource.split(guard).length - 1).toBe(1);
|
||||
|
||||
// Source order: special surfaces first, then the guarded editor wrapper.
|
||||
const markers = ["<DriverStorePage", "<EditorSettingsPage", guard, "<SqlEditorWorkspace", "<WelcomeScreen"];
|
||||
const indices = markers.map((marker) => appSource.indexOf(marker));
|
||||
expect(indices.every((index) => index >= 0)).toBe(true);
|
||||
expect([...indices].sort((a, b) => a - b)).toEqual(indices);
|
||||
});
|
||||
|
||||
it("keeps navigation independent from the workspace welcome content", () => {
|
||||
expect(appSource).toContain(':show-tab-navigation="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen || pluginCenterTabOpen"');
|
||||
expect(appSource).toContain(':active-tab="activeTab ?? undefined"');
|
||||
expect(appSource).toMatch(/<template #empty>\s*<WelcomeScreen/);
|
||||
expect(appSource).not.toContain('v-if="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen || pluginCenterTabOpen"');
|
||||
});
|
||||
|
||||
it("returns to an open special page after the final query tab closes", () => {
|
||||
expect(appSource).toContain("else if (previousId) activateOpenSpecialPageFallback();");
|
||||
expect(appSource).toMatch(
|
||||
/function activateOpenSpecialPageFallback\(\)[\s\S]*?settingsPageTabOpen\.value[\s\S]*?activateMainContentSurface\("settings"\)[\s\S]*?driverStoreTabOpen\.value[\s\S]*?activateMainContentSurface\("driverStore"\)[\s\S]*?pluginCenterTabOpen\.value[\s\S]*?activateMainContentSurface\("pluginCenter"\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("anchors the drag-back hit test on every pane strip and the special-surfaces bar", () => {
|
||||
const groupBarSource = readFileSync(new URL("../EditorGroupTabBar.vue", import.meta.url), "utf8");
|
||||
const slimBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8");
|
||||
expect(groupBarSource).toContain("data-main-tab-bar");
|
||||
expect(groupBarSource).toContain("'ring-2 ring-primary ring-inset': detachedDropTarget");
|
||||
expect(slimBarSource).toContain("data-special-page-tab-target");
|
||||
expect(slimBarSource).not.toContain("data-return-tab");
|
||||
// The split workspace renders several bars; the hit test must union their rects.
|
||||
expect(appSource).toContain('querySelectorAll<HTMLElement>("[data-main-tab-bar]")');
|
||||
});
|
||||
});
|
||||
-76
@@ -61,11 +61,7 @@ vi.mock("@/components/nacos/NacosRoleAccessControl.vue", async () => {
|
||||
|
||||
import NacosAccessControlConsole from "../NacosAccessControlConsole.vue";
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/nacos/NacosAccessControlConsole.vue"), "utf8");
|
||||
const legacySource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/nacos/NacosAccessControl.vue"), "utf8");
|
||||
const treeItemSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/sidebar/TreeItem.vue"), "utf8");
|
||||
const connectionTreeSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/sidebar/ConnectionTree.vue"), "utf8");
|
||||
const connectionStoreSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/stores/connectionStore.ts"), "utf8");
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
async function settle() {
|
||||
@@ -85,43 +81,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("NacosAccessControlConsole", () => {
|
||||
it("uses the access-control header pattern for its navigation", () => {
|
||||
expect(source).toContain('class="flex h-14 shrink-0 flex-wrap items-center gap-3 border-b px-4"');
|
||||
expect(source).toContain('class="flex rounded-md border p-0.5 shadow-sm"');
|
||||
expect(source).toContain("<UsersRound");
|
||||
expect(source).toContain("<KeyRound");
|
||||
expect(source).toContain('t("nacos.accessControlTitle")');
|
||||
expect(source).toContain('t("nacos.accessControlDescription")');
|
||||
});
|
||||
|
||||
it("only submits r-nacos namespace privilege fields supported by its API", () => {
|
||||
expect(legacySource).not.toContain("privilegeEnabled");
|
||||
expect(legacySource).toContain("whitelistIsAll: userForm.value.whitelistIsAll");
|
||||
expect(legacySource).toContain("blacklistIsAll: userForm.value.blacklistIsAll");
|
||||
expect(legacySource).toContain('import NacosNamespaceMultiSelect from "@/components/nacos/NacosNamespaceMultiSelect.vue"');
|
||||
expect(legacySource).toContain('v-model="userForm.whitelist"');
|
||||
expect(legacySource).toContain('v-model="userForm.blacklist"');
|
||||
expect(legacySource).toContain("whitelist: [...userForm.value.whitelist]");
|
||||
expect(legacySource).toContain("blacklist: [...userForm.value.blacklist]");
|
||||
expect(legacySource).not.toContain('<textarea v-model="userForm.whitelist"');
|
||||
expect(legacySource).not.toContain('<textarea v-model="userForm.blacklist"');
|
||||
});
|
||||
|
||||
it("invalidates the r-nacos namespace picker when namespaces change", () => {
|
||||
expect(legacySource).toContain('import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache"');
|
||||
expect(legacySource).toContain("stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged)");
|
||||
expect(legacySource).toContain("if (detail.connectionId !== props.connectionId) return");
|
||||
expect(legacySource).toContain("if (userDialogOpen.value && isRNacos.value) void loadUserFormNamespaces(true)");
|
||||
expect(legacySource).toContain("stopNacosNamespacesChangedListener?.()");
|
||||
});
|
||||
|
||||
it("notifies users when an official Nacos password reset succeeds or fails", () => {
|
||||
expect(legacySource).toContain('const userOperationNotice = ref<{ kind: "success" | "error"; message: string } | null>(null)');
|
||||
expect(legacySource).toContain("await syncCurrentConnectionPassword(username, userForm.value.password)");
|
||||
expect(legacySource).toContain('userOperationNotice.value = { kind: "error", message: t("nacos.passwordResetFailed", { message }) }');
|
||||
expect(legacySource).toContain('v-if="userOperationNotice"');
|
||||
});
|
||||
|
||||
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)"');
|
||||
@@ -129,29 +88,6 @@ describe("NacosAccessControlConsole", () => {
|
||||
expect(legacySource).toContain(':disabled="deletingUser || !userDeleteConfirmed"');
|
||||
});
|
||||
|
||||
it("does not hide user deletion solely because the user has ROLE_ADMIN", () => {
|
||||
expect(legacySource).not.toContain('if (user.roles.includes("ROLE_ADMIN")) return');
|
||||
expect(legacySource).not.toContain("v-if=\"!user.roles.includes('ROLE_ADMIN')\"");
|
||||
});
|
||||
|
||||
it("uses the same role creation icon in the legacy and enhanced workspaces", () => {
|
||||
expect(legacySource).toContain('import { KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, ShieldCheck, ShieldPlus, Trash2, UserRound } from "@lucide/vue"');
|
||||
expect(legacySource).toContain('@click="openAssignRole"><ShieldPlus class="h-3.5 w-3.5" />{{ t("nacos.assignRole") }}');
|
||||
expect(readFileSync(resolve(process.cwd(), "apps/desktop/src/components/nacos/NacosRoleAccessControl.vue"), "utf8")).toContain('<ShieldPlus v-else class="h-3.5 w-3.5" />');
|
||||
});
|
||||
|
||||
it("switches tabs when navigating between associated users and roles", () => {
|
||||
expect(source).toContain("@select-user=\"activeTab = 'users'\"");
|
||||
expect(source).toContain("@select-role=\"activeTab = 'roles'\"");
|
||||
});
|
||||
|
||||
it("keeps the roles-only workspace reachable without an unavailable banner", () => {
|
||||
expect(source).toContain('if (!supportsUsers.value && supportsRoles.value) activeTab.value = "roles"');
|
||||
expect(source).toContain('v-if="!supportsUsers && !supportsRoles"');
|
||||
expect(connectionStoreSource).toContain("sidebarSnapshot.accessControl.listUsers.supported === true ||");
|
||||
expect(connectionStoreSource).toContain("sidebarSnapshot.accessControl.listRoleBindings.supported === true");
|
||||
});
|
||||
|
||||
it("keeps the roles-only workspace visible alongside the permission warning", async () => {
|
||||
const unsupported = { supported: false, reason: "permissionDenied" as const };
|
||||
mocks.connectionInfo = {
|
||||
@@ -195,12 +131,6 @@ describe("NacosAccessControlConsole", () => {
|
||||
expect(workspace?.getAttribute("data-tab")).toBe("roles");
|
||||
});
|
||||
|
||||
it("shares the directory-detail workspace across supported Nacos versions", () => {
|
||||
expect(source).toContain('import NacosRoleAccessControl from "@/components/nacos/NacosRoleAccessControl.vue"');
|
||||
expect(source).toContain('<NacosRoleAccessControl v-if="enhancedWorkspace && accessControl"');
|
||||
expect(source).toContain(':capabilities="accessControl"');
|
||||
});
|
||||
|
||||
it("keeps the enhanced read workspace visible when writes are permission denied", async () => {
|
||||
const unsupported = { supported: false, reason: "permissionDenied" as const };
|
||||
const supported = { supported: true };
|
||||
@@ -290,10 +220,4 @@ describe("NacosAccessControlConsole", () => {
|
||||
expect(mocks.nacosTestConnection).toHaveBeenLastCalledWith("nacos-refresh", true);
|
||||
expect(mocks.refreshEnhancedWorkspace).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("internationalizes the sidebar entry, including trees persisted with the old label", () => {
|
||||
expect(connectionStoreSource).toContain('label: "nacos.accessControlSidebarLabel"');
|
||||
expect(treeItemSource).toContain('if (node.type === "nacos-access-control") return t("nacos.accessControlSidebarLabel")');
|
||||
expect(connectionTreeSource).toContain('node.type === "nacos-access-control" ? t("nacos.accessControlSidebarLabel") : node.label');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../NacosAdminConsole.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("NacosAdminConsole config workbench layout", () => {
|
||||
it("keeps access control outside the namespace-scoped workbench", () => {
|
||||
expect(source).not.toContain("NacosAccessControl");
|
||||
expect(source).toContain('type AdminTab = "configs" | "services";');
|
||||
});
|
||||
|
||||
it("offers browser-console and Zen-mode actions in the workspace toolbar", () => {
|
||||
expect(source).toContain("resolveNacosConsoleUrl");
|
||||
expect(source).toContain("function openNacosConsole()");
|
||||
expect(source).toContain('t("nacos.openConsole")');
|
||||
expect(source).toContain("emit('toggleZenMode')");
|
||||
expect(source).toContain('props.zenMode ? t("nacos.exitZenMode") : t("nacos.enterZenMode")');
|
||||
});
|
||||
|
||||
it("keeps JSON metadata available while offering an on-demand table dialog", () => {
|
||||
expect(source).toContain("function openInstanceMetadataTable(instance: NacosInstanceInfo)");
|
||||
expect(source).toContain('t("nacos.viewMetadataTable")');
|
||||
expect(source).toContain('class="nacos-instance-metadata-actions flex min-w-0 flex-wrap items-center gap-1"');
|
||||
expect(source).toContain('<details class="min-w-0 text-muted-foreground">');
|
||||
expect(source).not.toContain('<details class="min-w-0 flex-1 text-muted-foreground">');
|
||||
expect(source).toContain('v-model:open="instanceMetadataTableOpen"');
|
||||
expect(source).toContain('t("nacos.metadataKey")');
|
||||
expect(source).toContain('t("nacos.metadataValue")');
|
||||
expect(source).toContain("JSON.stringify(instance.metadata, null, 2)");
|
||||
});
|
||||
|
||||
it("keeps the editor as the final and primary workbench surface", () => {
|
||||
const contextBar = source.indexOf('class="nacos-config-context-bar');
|
||||
const inspector = source.indexOf('class="nacos-config-inspector');
|
||||
const toolbar = source.indexOf('class="nacos-editor-toolbar');
|
||||
const editor = source.indexOf('ref="configEditorHost"');
|
||||
|
||||
expect(contextBar).toBeGreaterThan(0);
|
||||
expect(inspector).toBeGreaterThan(contextBar);
|
||||
expect(toolbar).toBeGreaterThan(inspector);
|
||||
expect(editor).toBeGreaterThan(toolbar);
|
||||
});
|
||||
|
||||
it("uses split-pane container queries instead of viewport breakpoints", () => {
|
||||
expect(source).toContain(".nacos-config-workbench {\n container-type: inline-size;");
|
||||
expect(source).toContain("@container (min-width: 960px)");
|
||||
expect(source).toContain("@container (max-width: 480px)");
|
||||
expect(source.indexOf('class="nacos-editor-actions-secondary')).toBeLessThan(source.indexOf('class="nacos-editor-actions-primary'));
|
||||
expect(source).toContain("grid-template-columns: minmax(0, 1fr) auto;");
|
||||
});
|
||||
|
||||
it("tracks format and metadata changes as unsaved configuration state", () => {
|
||||
expect(source).toContain("configType.value !== originalConfigType.value");
|
||||
expect(source).toContain('(selectedConfig.value.appName || "") !== originalConfigMetadata.value.appName');
|
||||
expect(source).toContain('(selectedConfig.value.desc || "") !== originalConfigMetadata.value.desc');
|
||||
expect(source).toContain('(selectedConfig.value.tags || "") !== originalConfigMetadata.value.tags');
|
||||
});
|
||||
|
||||
it("returns a stale batch apply to preview instead of retrying an expired plan", () => {
|
||||
const staleBranch = source.indexOf('isNacosErrorCode(error, "stalePreview")');
|
||||
|
||||
expect(staleBranch).toBeGreaterThan(0);
|
||||
expect(source.indexOf("batchPreview.value = null;", staleBranch)).toBeGreaterThan(staleBranch);
|
||||
expect(source.indexOf("batchTransferRequest.value = null;", staleBranch)).toBeGreaterThan(staleBranch);
|
||||
expect(source.indexOf('batchError.value = t("nacos.previewExpired");', staleBranch)).toBeGreaterThan(staleBranch);
|
||||
});
|
||||
|
||||
it("keeps service detail and instance loading as independent guarded requests", () => {
|
||||
expect(source).toContain("const serviceDetailRequestGuard = createNacosLatestRequestGuard();");
|
||||
expect(source).toContain("const instancesRequestGuard = createNacosLatestRequestGuard();");
|
||||
expect(source).toContain("await Promise.all([loadServiceDetail(), loadInstances()]);");
|
||||
});
|
||||
|
||||
it("keeps configuration list responses scoped to the latest filters", () => {
|
||||
expect(source).toContain("const configListRequestGuard = createNacosLatestRequestGuard();");
|
||||
expect(source).toContain("const requestId = configListRequestGuard.begin();");
|
||||
expect(source).toContain("if (!isCurrentRequest()) return false;");
|
||||
expect(source).toContain("if (configListRequestGuard.isCurrent(requestId)) configLoading.value = false;");
|
||||
expect(source).toContain("const current = await loadConfigs(page);");
|
||||
expect(source).toContain("if (!current || !isConnectionNotFoundError(configError.value)");
|
||||
expect(source.match(/configListRequestGuard\.invalidate\(\);/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("keeps instance weight edits as drafts until the explicit save action", () => {
|
||||
expect(source).toContain("instanceWeightDrafts.value[instanceIdentity(instance)] = String(value);");
|
||||
expect(source).toContain('@click="requestInstanceWeightUpdate(instance)"');
|
||||
expect(source).not.toContain('@change="requestInstanceWeightUpdate(instance)"');
|
||||
});
|
||||
|
||||
it("tracks instance operations by token so stale requests cannot lock a row forever", () => {
|
||||
expect(source).toContain("const updatingInstanceKeys = ref<Record<string, number>>({});");
|
||||
expect(source).toContain("const operationToken = beginInstanceOperation(key);");
|
||||
expect(source).toContain("clearInstanceOperation(key, operationToken);");
|
||||
expect(source).not.toContain('if (updateId === instanceUpdateSequence) updatingInstanceKey.value = "";');
|
||||
});
|
||||
|
||||
it("prevents service-management dialogs from closing through outside clicks or Escape", () => {
|
||||
expect(source.match(/@pointer-down-outside\.prevent/g)?.length).toBeGreaterThanOrEqual(3);
|
||||
expect(source.match(/@interact-outside\.prevent/g)?.length).toBeGreaterThanOrEqual(3);
|
||||
expect(source.match(/@escape-key-down\.prevent/g)?.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("renders service details before the independently scrollable instance cards", () => {
|
||||
const detail = source.indexOf("nacos.serviceDetails");
|
||||
const instanceCards = source.indexOf('v-for="instance in instances"');
|
||||
expect(detail).toBeGreaterThan(0);
|
||||
expect(instanceCards).toBeGreaterThan(detail);
|
||||
});
|
||||
|
||||
it("keeps verbose service details collapsed until the user expands them", () => {
|
||||
expect(source).toContain("const serviceDetailExpanded = ref(restoredUiState.serviceDetailExpanded ?? false);");
|
||||
});
|
||||
|
||||
it("reserves the cluster-clear action space so entering a filter cannot reflow the toolbar", () => {
|
||||
expect(source).toContain('class="min-w-0 flex-1"');
|
||||
expect(source).toContain('class="nacos-service-filter-group flex min-w-0 items-center gap-1"');
|
||||
expect(source).toContain(':class="{ invisible: !serviceCluster }"');
|
||||
expect(source).toContain(':disabled="instancesLoading || !serviceCluster"');
|
||||
expect(source).not.toContain('v-if="serviceCluster"\n size="sm"');
|
||||
});
|
||||
|
||||
it("uses the service split pane width for responsive service and instance layouts", () => {
|
||||
expect(source).toContain(".nacos-service-workbench {\n container: nacos-service-workbench / inline-size;");
|
||||
expect(source).toContain("@container nacos-service-workbench (max-width: 480px)");
|
||||
expect(source).toContain("@container nacos-service-workbench (min-width: 620px)");
|
||||
expect(source).toContain("@container nacos-service-workbench (min-width: 720px)");
|
||||
expect(source).toContain('class="nacos-instance-layout flex gap-3"');
|
||||
expect(source).toContain('class="nacos-instance-actions flex flex-wrap items-center gap-2"');
|
||||
expect(source).toContain("overflow-wrap: anywhere;");
|
||||
expect(source).not.toContain("xl:flex-row xl:items-start xl:justify-between");
|
||||
expect(source).not.toContain("sm:grid-cols-[auto_minmax(0,1fr)]");
|
||||
});
|
||||
|
||||
it("adds icon-only clear controls to populated configuration and service filters", () => {
|
||||
for (const [filter, clear] of [
|
||||
["configDataId", "clearConfigFilter('dataId')"],
|
||||
["configGroup", "clearConfigFilter('group')"],
|
||||
["configAppName", "clearConfigFilter('appName')"],
|
||||
["serviceName", "clearServiceFilter('name')"],
|
||||
["serviceGroup", "clearServiceFilter('group')"],
|
||||
]) {
|
||||
expect(source).toContain(`v-if="${filter}"`);
|
||||
expect(source).toContain(`@click="${clear}"`);
|
||||
}
|
||||
expect(source).toContain("void loadConfigsWithRetry(1);");
|
||||
expect(source).toContain("void loadServicesWithRetry(1);");
|
||||
expect(source.match(/groupContains: true/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
expect(source.match(/:aria-label="t\('nacos\.clear'\)"/g)?.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("uses green outlined styling for healthy instances and red styling for unhealthy instances", () => {
|
||||
expect(source).toContain("instance.healthy === false ? 'border-destructive/50 text-destructive' : 'border-emerald-500/50 text-emerald-700 dark:text-emerald-300'");
|
||||
});
|
||||
|
||||
it("keeps the configuration editor zoom behavior aligned with the SQL editor", () => {
|
||||
expect(source).toContain("createEditorZoomCommitScheduler");
|
||||
expect(source).toContain("createEditorWheelZoomGestureGuard");
|
||||
expect(source).toContain("EditorView.domEventHandlers");
|
||||
expect(source).toContain("fontSizeFromWheelDelta(configEditorFontSize.value, event.deltaY)");
|
||||
expect(source).toContain("if (!configEditorWheelZoomGestureGuard.accepts(event)) return false;");
|
||||
expect(source).toContain("configEditorZoomCommitScheduler.dispose()");
|
||||
});
|
||||
|
||||
it("keeps the configuration editor word wrapping aligned with the global editor setting", () => {
|
||||
expect(source).toContain("const configEditorWordWrap = new Compartment()");
|
||||
expect(source).toContain("configEditorWordWrap.of(editorSettings.wordWrap ? EditorView.lineWrapping : [])");
|
||||
expect(source).toContain("configEditorWordWrap.reconfigure(settings.wordWrap ? EditorView.lineWrapping : [])");
|
||||
expect(source).not.toContain("\n EditorView.lineWrapping,\n");
|
||||
});
|
||||
|
||||
it("allows optional configuration columns to be hidden without hiding the data ID", () => {
|
||||
const configListHeader = source.indexOf('class="sticky top-0 z-20 grid border-b bg-muted');
|
||||
const columnVisibility = source.indexOf("nacos.visibleColumns");
|
||||
|
||||
expect(configListHeader).toBeGreaterThan(0);
|
||||
expect(columnVisibility).toBeGreaterThan(configListHeader);
|
||||
expect(source).toContain("configListToggleableColumns");
|
||||
expect(source).toContain("DropdownMenuCheckboxItem");
|
||||
expect(source).toContain("nacos.visibleColumns");
|
||||
expect(source).toContain('v-for="(column, columnIndex) in configListColumns"');
|
||||
expect(source).toContain("column === 'dataId'");
|
||||
});
|
||||
|
||||
it("marks the selected configuration without giving its row the header background", () => {
|
||||
expect(source).toContain("function isSelectedConfigListItem");
|
||||
expect(source).toContain(":class=\"{ 'border-l-2 border-l-primary': isSelectedConfigListItem(item) }\"");
|
||||
});
|
||||
|
||||
it("adds a guarded batch delete action for selected configurations", () => {
|
||||
const toolbar = source.indexOf('class="flex min-w-0 flex-wrap items-center justify-end gap-2"');
|
||||
const batchDeleteButton = source.indexOf("v-if=\"activeTab === 'configs' && selectedConfigCount > 0\"");
|
||||
|
||||
expect(toolbar).toBeGreaterThan(0);
|
||||
expect(batchDeleteButton).toBeGreaterThan(toolbar);
|
||||
expect(source).toContain('class="border-l border-current/30 pl-1.5 text-xs font-semibold tabular-nums"');
|
||||
expect(source).not.toContain('<Badge variant="outline" class="h-5 min-w-5 border-current px-1.5 text-current">{{ selectedConfigCount }}</Badge>');
|
||||
expect(source).toContain("const pendingBatchDelete = ref<NacosBatchDeleteSnapshot | null>(null);");
|
||||
expect(source).toContain("const canRequestBatchDeleteConfigs = computed(");
|
||||
expect(source).toContain("function requestBatchDeleteConfigs()");
|
||||
expect(source).toContain("async function deleteSelectedConfigs()");
|
||||
expect(source).toContain('await confirmNacosMutation(t("nacos.batchDelete"), snapshot.connectionId, snapshot.namespace)');
|
||||
expect(source).toContain("for (const key of snapshot.keys)");
|
||||
expect(source).toContain("await api.nacosDeleteConfig(snapshot.connectionId, key);");
|
||||
expect(source).toContain("function reconcileDeletedConfigSelection(deletedKeys: ReadonlySet<string>)");
|
||||
expect(source).toContain("reconcileDeletedConfigSelection(deletedKeys);");
|
||||
expect(source).toContain("nacos.batchDeletePartial");
|
||||
expect(source).toContain("nacos.batchDeleteInterrupted");
|
||||
});
|
||||
|
||||
it("allows the Nacos configuration page size to be changed within the supported range", () => {
|
||||
expect(source).toContain("const NACOS_CONFIG_PAGE_SIZE_OPTIONS = [20, 50, 100, 200, 500] as const;");
|
||||
expect(source).toContain('const NACOS_CONFIG_PAGE_SIZE_STORAGE_KEY = "dbx-nacos-config-page-size";');
|
||||
expect(source).toContain("function setConfigPageSize(value: string)");
|
||||
expect(source).toContain("safeLocalStorageSet(NACOS_CONFIG_PAGE_SIZE_STORAGE_KEY, String(nextPageSize));");
|
||||
expect(source).toContain('t("nacos.configPageSize")');
|
||||
expect(source).toContain('v-for="size in NACOS_CONFIG_PAGE_SIZE_OPTIONS"');
|
||||
expect(source).toContain('@update:model-value="setConfigPageSize(String($event))"');
|
||||
expect(source).toContain(":aria-label=\"t('nacos.configPageSize')\"");
|
||||
expect(source).toContain('class="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 border-t px-3 py-2 text-xs text-muted-foreground"');
|
||||
expect(source).not.toContain('class="flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t px-3 py-2 text-xs text-muted-foreground"');
|
||||
const footerStart = source.indexOf('class="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 border-t px-3 py-2 text-xs text-muted-foreground"');
|
||||
const footerEnd = source.indexOf("</div>\n </div>\n </Pane>", footerStart);
|
||||
expect(source.slice(footerStart, footerEnd)).not.toContain('t("nacos.selectedCount", { count: selectedConfigCount })');
|
||||
expect(source).toContain('<ChevronLeft class="h-3.5 w-3.5" />');
|
||||
expect(source).toContain('<ChevronRight class="h-3.5 w-3.5" />');
|
||||
});
|
||||
|
||||
it("uses semantic coloring for instance enable and disable actions", () => {
|
||||
expect(source).toContain("border-emerald-500/50 text-emerald-700");
|
||||
expect(source).toContain("border-destructive/50 text-destructive hover:bg-destructive/10");
|
||||
});
|
||||
|
||||
it("hides instance operations that the selected API plane does not support", () => {
|
||||
expect(source).toContain('v-if="registerInstanceCapability.supported"');
|
||||
expect(source).toContain('v-if="updateInstanceHealthCapability.supported"');
|
||||
expect(source).toContain('v-if="deregisterInstanceCapability.supported"');
|
||||
});
|
||||
|
||||
it("separates the service header, filtering controls, and management actions", () => {
|
||||
expect(source).toContain('<header class="shrink-0 border-b bg-background">');
|
||||
expect(source).toContain('class="nacos-service-toolbar flex flex-wrap items-center gap-x-4 gap-y-2 border-t bg-muted/30 px-4 py-2"');
|
||||
expect(source).toContain('t("nacos.serviceSettings")');
|
||||
expect(source).toContain('t("nacos.registerInstance")');
|
||||
});
|
||||
|
||||
it("gates every Nacos mutation behind the shared production confirmation", () => {
|
||||
expect(source).toContain('import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";');
|
||||
expect(source).toContain("async function confirmNacosMutation");
|
||||
expect(source).toContain('<ProductionContextBadge v-if="nacosProductionContext.active" compact />');
|
||||
expect(source.match(/await confirmNacosMutation\(/g)?.length).toBeGreaterThanOrEqual(9);
|
||||
for (const apiCall of ["nacosApplyConfigImport", "nacosApplyConfigTransfer", "nacosRollbackConfig", "nacosPublishConfig", "nacosDeleteConfig", "nacosUpdateInstance", "nacosCreateService", "nacosDeleteService", "nacosRegisterInstance", "nacosDeregisterInstance"]) {
|
||||
expect(source).toContain(apiCall);
|
||||
}
|
||||
});
|
||||
});
|
||||
-50
@@ -11,24 +11,6 @@ const source = readFileSync(new URL("../NacosAdminConsole.vue", import.meta.url)
|
||||
* wiring that only exists inside this component, which is too large to mount.
|
||||
*/
|
||||
describe("NacosAdminConsole YAML diagnostics wiring", () => {
|
||||
it("installs and seeds the linter only for YAML configs", () => {
|
||||
expect(source).toContain('...(format === "yaml" ? [nacosConfigYamlLintExtension(configDiagnosticTranslate)] : []),');
|
||||
expect(source).toContain('if (format === "yaml") view.dispatch(setDiagnostics(view.state, nacosConfigYamlLintDiagnostics(view.state.doc.toString(), configDiagnosticTranslate)));');
|
||||
// A YAML-only extension: each helper has exactly one call site, inside the gated branch.
|
||||
expect(source.match(/nacosConfigYamlLintExtension\(/g)).toHaveLength(1);
|
||||
expect(source.match(/nacosConfigYamlLintDiagnostics\(/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the lint logic out of the component", () => {
|
||||
// The component only formats and displays diagnostics; it never parses YAML itself.
|
||||
expect(source).not.toContain("analyzeNacosYaml(");
|
||||
expect(source).not.toContain("parseAllDocuments(");
|
||||
expect(source).not.toContain("parseDocument(");
|
||||
expect(source).toContain('import { setDiagnostics } from "@codemirror/lint";');
|
||||
expect(source).toContain('import { nacosConfigYamlLintDiagnostics, nacosConfigYamlLintExtension } from "@/lib/nacos/nacosConfigYamlLint";');
|
||||
expect(source).toContain('import { translateNacosYamlDiagnostic } from "@/lib/nacos/nacosYamlDiagnostics";');
|
||||
});
|
||||
|
||||
it("blocks publishing on error severity only", () => {
|
||||
expect(source).toContain("if (nacosConfigValidationBlocksPublish(diagnostics)) {");
|
||||
expect(source).not.toContain('diagnostics.some((diagnostic) => nacosConfigDiagnosticSeverity(diagnostic) === "error")');
|
||||
@@ -37,22 +19,6 @@ describe("NacosAdminConsole YAML diagnostics wiring", () => {
|
||||
expect(source).toContain("if (!showSuccess) return true;");
|
||||
});
|
||||
|
||||
it("does not double-mark YAML diagnostics with the legacy decoration", () => {
|
||||
expect(source).toContain('const configUsesYamlLint = computed(() => resolveNacosConfigFormat(configType.value, configDataId.value) === "yaml");');
|
||||
expect(source).toContain("configValidationHighlightActive = !configUsesYamlLint.value;");
|
||||
expect(source).toContain("effects: setConfigValidationHighlight.of(configUsesYamlLint.value ? [] : configValidationDiagnostics.value),");
|
||||
});
|
||||
|
||||
it("reports severity and localized messages in the validation dialog", () => {
|
||||
expect(source).toContain('{{ configValidationHasError ? t("nacos.validationErrorTitle") : t("nacos.validationWarningTitle") }}');
|
||||
expect(source).toContain('{{ configValidationHasError ? t("nacos.validationErrorDescription") : t("nacos.validationWarningDescription") }}');
|
||||
// Asserted separately because the formatter may wrap the spans.
|
||||
expect(source).toContain("{{ configValidationSeverityLabel(diagnostic) }}");
|
||||
expect(source).toContain("{{ configValidationMessage(diagnostic) }}");
|
||||
// The localized text is resolved through the shared helper, never a raw parser string.
|
||||
expect(source).toContain("return diagnostic.code ? translateNacosYamlDiagnostic({ code: diagnostic.code, message: diagnostic.message, params: diagnostic.params ?? {} }, configDiagnosticTranslate) : diagnostic.message;");
|
||||
});
|
||||
|
||||
it("localizes every key it references, including the new severity labels", () => {
|
||||
const referenced = [
|
||||
"validationErrorTitle",
|
||||
@@ -72,20 +38,4 @@ describe("NacosAdminConsole YAML diagnostics wiring", () => {
|
||||
expect((nacos[key] as string).length, key).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds no watcher, listener, or observer for live diagnostics", () => {
|
||||
// CodeMirror owns the debounce; a component-level watcher would re-parse on
|
||||
// every keystroke and leak across config switches.
|
||||
expect(source.match(/watch\(/g)).toHaveLength(6);
|
||||
expect(source.match(/addEventListener\(/g)).toHaveLength(1);
|
||||
expect(source.match(/removeEventListener\(/g)).toHaveLength(1);
|
||||
expect(source.match(/onMounted\(/g)).toHaveLength(1);
|
||||
expect(source.match(/new ResizeObserver/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still tears the editor view down when the config changes", () => {
|
||||
expect(source).toContain("configEditorView.value?.destroy();");
|
||||
expect(source).toContain("configEditorView.value = null;");
|
||||
expect(source).toContain("await refreshConfigEditor();");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,31 +4,6 @@ import { describe, expect, it } from "vitest";
|
||||
const source = readFileSync(new URL("../NacosRoleAccessControl.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("NacosRoleAccessControl", () => {
|
||||
it("uses a directory-detail layout for both users and roles", () => {
|
||||
expect(source).toContain('class="flex w-64 shrink-0 flex-col border-r');
|
||||
expect(source).toContain("v-if=\"tab === 'users'\"");
|
||||
expect(source).toContain("v-else-if=\"tab === 'roles' && selectedRole\"");
|
||||
});
|
||||
|
||||
it("places the contextual create action beside search instead of duplicating refresh", () => {
|
||||
expect(source).toContain(":aria-label=\"tab === 'users' ? t('nacos.createUser') : t('nacos.accessCreateRole')\"");
|
||||
expect(source).toContain("@click=\"tab === 'users' ? openCreateUser() : openCreateRole()\"");
|
||||
expect(source).toContain("<UserPlus v-if=\"tab === 'users'\"");
|
||||
expect(source).toContain("<ShieldPlus v-else");
|
||||
expect(source).not.toContain(":aria-label=\"t('nacos.refresh')\"");
|
||||
expect(source).not.toContain('class="h-8 w-full"');
|
||||
});
|
||||
|
||||
it("keeps role creation complete with per-namespace permissions", () => {
|
||||
expect(source).toContain("roleForm.newUsers");
|
||||
expect(source).toContain("moveNamespacesToGranted");
|
||||
expect(source).toContain('action: "rw"');
|
||||
expect(source).toContain("setPermissionAction");
|
||||
expect(source).toContain("accessUnassignedNamespaces");
|
||||
expect(source).toContain("accessGrantedNamespaces");
|
||||
expect(source).toContain('kind: "createRole"');
|
||||
});
|
||||
|
||||
it("keeps the selected role identity when opening the delete dialog", () => {
|
||||
expect(source).toContain("resetRoleForm(selectedRole.value.role)");
|
||||
});
|
||||
@@ -46,67 +21,4 @@ describe("NacosRoleAccessControl", () => {
|
||||
expect(source).toContain('t("nacos.accessDeleteUserConfirmationHint")');
|
||||
expect(source).toContain("userDialog === 'delete' && !userDeleteConfirmed");
|
||||
});
|
||||
|
||||
it("lets the backend workflow protect only the final ROLE_ADMIN member", () => {
|
||||
expect(source).toContain("if (!selectedUser.value) return");
|
||||
expect(source).not.toContain('selectedUser.value.roles.includes("ROLE_ADMIN")');
|
||||
expect(source).not.toContain("v-if=\"!selectedUser.roles.includes('ROLE_ADMIN')\"");
|
||||
});
|
||||
|
||||
it("shows complete IDs only in the unassigned namespace list", () => {
|
||||
expect(source).toContain('class="block truncate font-medium" :title="namespace.name"');
|
||||
expect(source).toContain('class="block break-all text-[10px] leading-4 text-muted-foreground" :title="namespace.id"');
|
||||
expect(source).not.toContain('<code v-if="namespace.name !== namespace.namespaceId"');
|
||||
});
|
||||
|
||||
it("uses a wider, internally scrollable namespace picker and filters by name or ID", () => {
|
||||
expect(source).toContain('class="max-h-[90vh] overflow-auto sm:max-w-3xl"');
|
||||
expect(source).toContain("h-[min(28rem,45vh)]");
|
||||
expect(source).toContain('const id = item.id ?? item.namespaceId ?? ""');
|
||||
expect(source).toContain("item.name.toLocaleLowerCase().includes(normalized) || id.toLocaleLowerCase().includes(normalized)");
|
||||
expect(source).toContain(":aria-label=\"t('nacos.accessSearchNamespaces')\"");
|
||||
});
|
||||
|
||||
it("refreshes the role namespace options after a namespace change for this connection", () => {
|
||||
expect(source).toContain('import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache"');
|
||||
expect(source).toContain("function handleNacosNamespacesChanged(detail: NacosNamespacesChangedDetail)");
|
||||
expect(source).toContain("if (detail.connectionId === props.connectionId) void loadSnapshot()");
|
||||
expect(source).toContain("stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged)");
|
||||
expect(source).toContain("stopNacosNamespacesChangedListener?.()");
|
||||
});
|
||||
|
||||
it("merges split permission rows before editing an existing role", () => {
|
||||
expect(source).toContain("mergeNacosNamespacePermissionAssignments(managedRolePermissions.value)");
|
||||
});
|
||||
|
||||
it("gates every mutation by its probed operation capability", () => {
|
||||
expect(source).toContain("capabilities: NacosAccessControlCapabilities");
|
||||
expect(source).toContain("const canCreateUser = computed");
|
||||
expect(source).toContain("const canGrantPermission = computed");
|
||||
expect(source).toContain("const userOperationWritable = computed");
|
||||
expect(source).toContain("const roleOperationWritable = computed");
|
||||
expect(source).toContain(':disabled="saving || !userOperationWritable ||');
|
||||
expect(source).toContain(':disabled="saving || !roleOperationWritable ||');
|
||||
});
|
||||
|
||||
it("surfaces partial operations instead of hiding them in a toast", () => {
|
||||
expect(source).toContain("operationNeedsPasswords");
|
||||
expect(source).toContain("nacosRetryAccessOperation");
|
||||
expect(source).toContain("nacosUndoAccessOperation");
|
||||
expect(source).toContain("accessOperationResult");
|
||||
});
|
||||
|
||||
it("uses the global notification for password reset results", () => {
|
||||
expect(source).toContain('import { useToast } from "@/composables/useToast"');
|
||||
expect(source).toContain("const { toast } = useToast()");
|
||||
expect(source).toContain('toast(t("nacos.passwordResetSucceeded", { username }), 2500)');
|
||||
expect(source).toContain('toast(t("nacos.passwordResetFailed", { message }), 5000)');
|
||||
});
|
||||
|
||||
it("renders associated roles with the same navigable card pattern as role members", () => {
|
||||
expect(source).toContain('v-if="selectedUser.roles.length" class="flex flex-wrap gap-1.5 rounded border bg-muted/20 p-1.5"');
|
||||
expect(source).toContain(":title=\"t('nacos.accessOpenRole', { role })\"");
|
||||
expect(source).toContain('@click="openAssociatedRole(role)"');
|
||||
expect(source).toContain('emit("select-role")');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import objectBrowserSource from "./ObjectBrowser.vue?raw";
|
||||
|
||||
describe("ObjectBrowser table clipboard context menu", () => {
|
||||
it("resolves recycled row menus when they are opened", () => {
|
||||
const lazyMenuBindings = objectBrowserSource.match(/:items="\(\) => getObjectBrowserMenuItems\(item\)"/g);
|
||||
|
||||
expect(lazyMenuBindings).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps paste on the copied row and allows replacing it from another row", () => {
|
||||
expect(objectBrowserSource).toMatch(/function tableClipboardMenuItems\(item: ObjectBrowserRow\)[\s\S]*?objectBrowserTableClipboardMenuState\(item\)[\s\S]*?state === "copy"[\s\S]*?state === "paste" \? \[pasteItem\] : \[copyItem, pasteItem\]/);
|
||||
expect(objectBrowserSource).toContain("...tableClipboardMenuItems(item)");
|
||||
});
|
||||
|
||||
it("normalizes copied and target schemas before paste validation", () => {
|
||||
expect(objectBrowserSource).toMatch(/function normalizedObjectBrowserTableClipboardEntries\(\)[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(entry\.schema, entry\.database, entry\.connectionId\)/);
|
||||
expect(objectBrowserSource).toMatch(/function canPasteTableClipboard\(\)[\s\S]*?tableClipboardMatchesTarget\(normalizedObjectBrowserTableClipboardEntries\(\), pasteTableTargetContext\(\)\)/);
|
||||
expect(objectBrowserSource).toMatch(/function pasteTableTargetContext\(\)[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(selectedSchema\.value\)/);
|
||||
expect(objectBrowserSource).toMatch(/pasteTableEntries\.value = clipboard\.tables\.map[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(entry\.schema, entry\.database, entry\.connectionId\)/);
|
||||
expect(objectBrowserSource).toMatch(/function normalizeObjectBrowserTableClipboardSchema[\s\S]*?connectionStore\.getConfig\(connectionId\) \?\? props\.connection[\s\S]*?!isSchemaAware\(connection\.db_type\)[\s\S]*?connection\.db_type !== "sqlite"[\s\S]*?return undefined/);
|
||||
});
|
||||
|
||||
it("consumes only the clipboard used by a fully successful paste", () => {
|
||||
expect(objectBrowserSource).toMatch(
|
||||
/async function confirmPasteTable\(\)[\s\S]*?const clipboardAtPasteStart = connectionStore\.treeClipboard[\s\S]*?const pasteFeedback = tablePasteFeedback\(successCount, pasteFailCount, firstPasteError\)[\s\S]*?if \(pasteFailCount === 0\)[\s\S]*?connectionStore\.treeClipboard === clipboardAtPasteStart[\s\S]*?connectionStore\.treeClipboard = null/,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the first paste failure reason without concatenating every error", () => {
|
||||
expect(objectBrowserSource).toMatch(/pasteFailCount\+\+/);
|
||||
expect(objectBrowserSource).toMatch(/firstPasteError \?\?= e/);
|
||||
expect(objectBrowserSource).toMatch(/batchPastePartialFail[\s\S]*?tableOperationFailed[\s\S]*?translateBackendError\(t, pasteFeedback\.firstError\)/);
|
||||
});
|
||||
|
||||
it("refreshes created tables and retains the clipboard when a later paste step is cancelled", () => {
|
||||
expect(objectBrowserSource).toMatch(/let pasteCancelled = false[\s\S]*?let hasMutatedTable = false/);
|
||||
expect(objectBrowserSource).toMatch(/if \(!executed\) \{[\s\S]*?pasteCancelled = true;[\s\S]*?break;/);
|
||||
expect(objectBrowserSource).toMatch(/if \(pasteCancelled\) \{[\s\S]*?if \(hasMutatedTable\)[\s\S]*?await reload\(\)[\s\S]*?refreshObjectListTreeNode[\s\S]*?pasteTableCancelledAfterPartial[\s\S]*?return;/);
|
||||
});
|
||||
|
||||
it("carries table comments through local copy and paste", () => {
|
||||
expect(objectBrowserSource).toMatch(/tableName: row\.name,\s*tableComment: row\.comment/);
|
||||
expect(objectBrowserSource).toMatch(/targetName: `\$\{entry\.tableName\}_copy`,[\s\S]*?tableComment: entry\.tableComment/);
|
||||
expect(objectBrowserSource).toMatch(/buildDuplicateStructurePlan\(entry\.sourceName, targetName, schema, entry\.tableComment/);
|
||||
});
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../DatabaseBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("DatabaseBrowser metadata fallback", () => {
|
||||
it("falls back to basic database names when extended metadata fails", () => {
|
||||
expect(source).toMatch(/await api\.listDatabaseMetadata\(props\.connection\.id\)[\s\S]*?catch \(metadataError\)[\s\S]*?await api\.listDatabases\(props\.connection\.id\)/);
|
||||
expect(source).toMatch(/catch \{\s*throw metadataError;\s*\}/);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import DdlScopeFixture from "./DdlDialog.legacyScope.fixture.vue";
|
||||
|
||||
const ddlViewDialogSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/objects/DdlViewDialog.vue"), "utf8");
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
@@ -61,14 +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 sm:max-w-190"');
|
||||
});
|
||||
});
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const objectBrowserSource = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ObjectBrowser cross-database table paste", () => {
|
||||
it("routes a foreign table clipboard to the shared data-transfer dialog", () => {
|
||||
expect(objectBrowserSource).toContain("function canTransferTableClipboard()");
|
||||
expect(objectBrowserSource).toContain("function openTransferFromTableClipboard()");
|
||||
expect(objectBrowserSource).toContain("targetConnectionId: target.connectionId");
|
||||
expect(objectBrowserSource).toContain("targetDatabase: target.database");
|
||||
expect(objectBrowserSource).toMatch(/function objectBrowserTableClipboardMenuState[\s\S]*?tableClipboardMenuState\([\s\S]*?canTransferTableClipboard\(\),\s*\)/);
|
||||
});
|
||||
|
||||
it("keeps the local duplicate-table paste path available", () => {
|
||||
expect(objectBrowserSource).toContain("if (canTransferTableClipboard()) {");
|
||||
expect(objectBrowserSource).toContain("if (!canPasteTableClipboard() && !canTransferTableClipboard()) return;");
|
||||
expect(objectBrowserSource).toContain("pasteTableMode.value = defaultPasteTableMode(effectiveDatabaseType.value);");
|
||||
expect(objectBrowserSource).toContain('normalizeNewTargetName: mode === "structure-and-data"');
|
||||
});
|
||||
});
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const objectBrowserSource = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
const contentAreaSource = readFileSync(new URL("../../layout/ContentArea.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionBody(name: string, source: string): string {
|
||||
const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source);
|
||||
if (!signature) throw new Error(`Missing function ${name}`);
|
||||
const bodyStart = signature.index + signature[0].length;
|
||||
let depth = 1;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart, index);
|
||||
}
|
||||
throw new Error(`Unclosed function ${name}`);
|
||||
}
|
||||
|
||||
describe("ObjectBrowser MySQL Event CREATE navigation", () => {
|
||||
it("accepts an explicit create request prop next to the edit props", () => {
|
||||
expect(objectBrowserSource).toContain("initialEventCreateRequestId?: number;");
|
||||
expect(objectBrowserSource).toMatch(/initialEventName\?: string;[\s\S]*?initialEventCreateRequestId\?: number;[\s\S]*?initialObjectFilter\?: "tables" \| "events";/);
|
||||
});
|
||||
|
||||
it("routes the initial event request through the shared resolver", () => {
|
||||
const openInitialEventIfNeeded = functionBody("openInitialEventIfNeeded", objectBrowserSource);
|
||||
expect(openInitialEventIfNeeded).toContain("resolveInitialEventEditorRequest({");
|
||||
expect(openInitialEventIfNeeded).toContain("eventCreateRequestId: props.initialEventCreateRequestId");
|
||||
expect(openInitialEventIfNeeded).toContain("openedRequestKey: openedInitialEvent.value");
|
||||
expect(openInitialEventIfNeeded).toContain("hasEventRow: rows.value.some");
|
||||
expect(openInitialEventIfNeeded).toContain("loadingObjects: loadingObjects.value");
|
||||
});
|
||||
|
||||
it("opens the CREATE editor without any existing EVENT row", () => {
|
||||
const openInitialEventIfNeeded = functionBody("openInitialEventIfNeeded", objectBrowserSource);
|
||||
expect(openInitialEventIfNeeded).toContain('if (decision.type === "create") {');
|
||||
expect(openInitialEventIfNeeded).toContain("sidePanelRow.value = null;");
|
||||
expect(openInitialEventIfNeeded).toContain('sidePanelMode.value = "event-editor";');
|
||||
expect(openInitialEventIfNeeded).toContain("openedInitialEvent.value = decision.requestKey;");
|
||||
// The editor must see an empty name so MySqlEventEditor renders CREATE mode
|
||||
expect(openInitialEventIfNeeded).not.toContain("sidePanelRow.value = row;");
|
||||
});
|
||||
|
||||
it("keeps the CREATE editor mounted when there is no EVENT row", () => {
|
||||
expect(objectBrowserSource).toContain('v-if="sidePanelRow || isEventEditor"');
|
||||
expect(objectBrowserSource).toContain('<MySqlEventEditor :key="eventEditorKey"');
|
||||
});
|
||||
|
||||
it("renders the editor with an empty CREATE name and request-scoped instance", () => {
|
||||
expect(objectBrowserSource).toContain('<MySqlEventEditor :key="eventEditorKey" :connection="props.connection" :database="props.database" :schema="sidePanelRow?.schema || selectedSchema || props.database" :name="sidePanelRow?.name"');
|
||||
expect(objectBrowserSource).toMatch(/const eventEditorKey = computed\(\(\) =>[\s\S]*?createRequestId: props\.initialEventCreateRequestId[\s\S]*?openRequestId: props\.initialEventOpenRequestId[\s\S]*?rowId: sidePanelRow\.value\?\.id/);
|
||||
});
|
||||
|
||||
it("re-triggers a create request when the request id changes on a reused tab", () => {
|
||||
expect(objectBrowserSource).toContain("() => props.initialEventCreateRequestId");
|
||||
expect(objectBrowserSource).toMatch(/watch\(\[\(\) => props\.initialEventName, \(\) => props\.initialEventOpenRequestId, \(\) => props\.initialEventCreateRequestId\][\s\S]*?openedInitialEvent\.value = "";[\s\S]*?openInitialEventIfNeeded\(\);/);
|
||||
expect(objectBrowserSource).toContain('if (name || createRequestId !== undefined) objectFilter.value = "events";');
|
||||
});
|
||||
|
||||
it("lists the events filter as the preferred view for a create request", () => {
|
||||
expect(objectBrowserSource).toMatch(/const preferredFilter = props\.initialEventName \|\| props\.initialEventCreateRequestId !== undefined \? "events" : \(props\.selectedObjectFilter \?\? props\.initialObjectFilter \?\? "tables"\);/);
|
||||
});
|
||||
|
||||
it("ContentArea passes the create request id into ObjectBrowser", () => {
|
||||
expect(contentAreaSource).toContain(':initial-event-name="activeTab.objectBrowser?.eventName"');
|
||||
expect(contentAreaSource).toContain(':initial-event-create-request-id="activeTab.objectBrowser?.eventCreateRequestId"');
|
||||
});
|
||||
});
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const objectBrowserSource = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ObjectBrowser MongoDB collection statistics (#9705)", () => {
|
||||
it("requests table statistics for every engine, MongoDB included", () => {
|
||||
// The loader used to skip MongoDB wholesale, so the columns below stayed
|
||||
// empty even though the backend can now answer `collStats` per collection.
|
||||
expect(objectBrowserSource).not.toContain('if (props.connection.db_type !== "mongodb") void loadObjectStatistics');
|
||||
expect(objectBrowserSource).toContain("void loadObjectStatistics(request, cacheWriteToken, cachedAt);");
|
||||
});
|
||||
|
||||
it("renders the row/size columns and their sort keys for MongoDB", () => {
|
||||
expect(objectBrowserSource).toContain("const showObjectRowStats = computed(() => showTableStatistics.value);");
|
||||
expect(objectBrowserSource).toContain("const supportsObjectSizeStats = computed(() => !isVictoriaMetrics.value);");
|
||||
});
|
||||
|
||||
it("keeps the batch table toolbar SQL-only", () => {
|
||||
expect(objectBrowserSource).toContain("const supportsBatchTableActions = computed(() => !isVictoriaMetrics.value && !isMongodb.value);");
|
||||
for (const handler of ["openBatchDatabaseExport", "copySelectedTablesToClipboard", "requestBatchTruncateTables", "requestBatchEmptyTables", "requestBatchDropTables"]) {
|
||||
const button = new RegExp(`v-if="supportsBatchTableActions[^"]*"[^>]*@click="${handler}"`);
|
||||
expect(objectBrowserSource).toMatch(button);
|
||||
}
|
||||
// No toolbar action may key off the stats-column flag any more.
|
||||
expect(objectBrowserSource).not.toContain('v-if="supportsObjectSizeStats');
|
||||
});
|
||||
});
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const objectBrowserSource = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionBody(name: string, source: string): string {
|
||||
const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source);
|
||||
if (!signature) throw new Error(`Missing function ${name}`);
|
||||
const bodyStart = signature.index + signature[0].length;
|
||||
let depth = 1;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart, index);
|
||||
}
|
||||
throw new Error(`Unclosed function ${name}`);
|
||||
}
|
||||
|
||||
describe("ObjectBrowser object-list schema fallback (#8301)", () => {
|
||||
it("resolves the listObjects schema through the shared Dameng-aware helper", () => {
|
||||
const loadObjects = functionBody("loadObjects", objectBrowserSource);
|
||||
expect(loadObjects).toContain("needsSchema.value ? objectListSchemaForConnection(props.connection, selectedSchema.value) : props.database");
|
||||
expect(objectBrowserSource).toContain('objectListSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"');
|
||||
});
|
||||
|
||||
it("routes the schema through the request scope used by listObjects", () => {
|
||||
const loadSqlObjectBrowserRows = functionBody("loadSqlObjectBrowserRows", objectBrowserSource);
|
||||
expect(loadSqlObjectBrowserRows).toContain("api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema");
|
||||
const loadObjects = functionBody("loadObjects", objectBrowserSource);
|
||||
expect(loadObjects).toContain("objectBrowserRowsCacheScope(schema)");
|
||||
});
|
||||
|
||||
it("keeps the reload chain alive when loadSchemas rejects", () => {
|
||||
const reload = functionBody("reload", objectBrowserSource);
|
||||
expect(reload).toMatch(/try \{\s*if \(!\(await loadSchemas\(epoch\)\)\) return;\s*\} catch/);
|
||||
const catchClause = reload.slice(reload.indexOf("} catch"));
|
||||
// The failure path must not clear the selection (props.schema / current value)
|
||||
// and must still load objects — Dameng then falls back to the username.
|
||||
expect(catchClause).not.toContain("selectedSchema.value =");
|
||||
expect(catchClause).toContain("console.warn");
|
||||
expect(reload.lastIndexOf("await loadObjects")).toBeGreaterThan(reload.indexOf("} catch"));
|
||||
});
|
||||
|
||||
it("leaves the selected schema untouched when listSchemas itself rejects", () => {
|
||||
const loadSchemas = functionBody("loadSchemas", objectBrowserSource);
|
||||
// Every selectedSchema assignment in the try block sits behind the awaited
|
||||
// listSchemas call, so a rejection keeps the current selection intact.
|
||||
const tryStart = loadSchemas.indexOf("try {");
|
||||
const apiCall = loadSchemas.indexOf("await api.listSchemas", tryStart);
|
||||
const firstAssignment = loadSchemas.indexOf("selectedSchema.value =", apiCall);
|
||||
expect(apiCall).toBeGreaterThan(tryStart);
|
||||
expect(firstAssignment).toBeGreaterThan(apiCall);
|
||||
});
|
||||
});
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Issue #9200: when the "Browse Objects" pane is narrow, the keyword search box
|
||||
// collapsed to a sliver and the "全部/表/视图" chips were painted on top of it,
|
||||
// instead of the toolbar condensing its controls into the overflow menu.
|
||||
//
|
||||
// useToolbarOverflow only condenses while the row reports real overflow, and its
|
||||
// contract requires "non-shrinking (shrink-0) or floored (min-w-*) children":
|
||||
// 1. the collapsed search wrapper was `min-w-0`, so the wrapper dropped to 0
|
||||
// width while the input inside still painted at its padding width (58px),
|
||||
// drawing on top of the neighbouring filter chips;
|
||||
// 2. the search + filter group carried `min-w-24`, which overrode the
|
||||
// content-based minimum and let the group shrink below its own content, so
|
||||
// its children spilled into the sort control instead of overflowing the row;
|
||||
// 3. the filter chips allowed their labels to wrap, so the group's min-content
|
||||
// (longest word) was far below the width the chips actually occupy.
|
||||
// Each of the three is load-bearing; removing any one of them brings the overlap
|
||||
// back at some pane width.
|
||||
|
||||
const objectBrowserSource = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ObjectBrowser toolbar condenses instead of overlapping when narrow", () => {
|
||||
it("floors the search box and lets the search + filter group keep its content minimum", () => {
|
||||
// The floored wrapper keeps the input from painting over its neighbours, and
|
||||
// dropping `min-w-24` lets the group's content-based minimum stop the spill
|
||||
// into the sort control, so the row reports overflow instead.
|
||||
expect(objectBrowserSource).toMatch(/<div class="flex flex-1 items-center gap-2">\s*<div class="relative min-w-\[6rem\] flex-1">/);
|
||||
});
|
||||
|
||||
it("pins the filter chips to their single-line width so the row reports overflow", () => {
|
||||
expect(objectBrowserSource).toMatch(/<button\s+v-for="filter in objectFilters"[\s\S]*?class="h-6 shrink-0 whitespace-nowrap rounded-sm/);
|
||||
});
|
||||
});
|
||||
Vendored
-41
@@ -1,41 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
// Regression test for #8885: when the object-browser columns are wider than the
|
||||
// panel, the vertical scrollbar must stay pinned to the visible right edge
|
||||
// instead of travelling with the content. The list scroller therefore owns the
|
||||
// horizontal scrolling (with the content width applied to its inner item
|
||||
// wrapper), while the header is clipped and aligned programmatically.
|
||||
describe("ObjectBrowser vertical scrollbar viewport lock", () => {
|
||||
it("keeps the outer table container from scrolling horizontally", () => {
|
||||
const tableMatch = /class="object-browser-table[^"]*"/.exec(source);
|
||||
expect(tableMatch).not.toBeNull();
|
||||
expect(tableMatch![0]).toContain("overflow-hidden");
|
||||
expect(tableMatch![0]).not.toContain("overflow-x-auto");
|
||||
});
|
||||
|
||||
it("moves the content min-width onto the scroller's inner wrapper", () => {
|
||||
const scrollerMatch = /<RecycleScroller[^>]*listScrollerRef[^>]*>/.exec(source);
|
||||
expect(scrollerMatch).not.toBeNull();
|
||||
expect(scrollerMatch![0]).toContain("--dbx-object-grid-min-width");
|
||||
expect(scrollerMatch![0]).not.toContain("minWidth:");
|
||||
expect(source).toContain(".object-browser-scroller {");
|
||||
expect(source).toContain("overflow-x: auto;");
|
||||
expect(source).toContain("min-width: var(--dbx-object-grid-min-width, 0px);");
|
||||
});
|
||||
|
||||
it("clips the header and syncs its scroll position with the scroller", () => {
|
||||
const headerMatch = /ref="objectListHeaderRef"[^>]*/.exec(source);
|
||||
expect(headerMatch).not.toBeNull();
|
||||
expect(headerMatch![0]).toContain("overflow-hidden");
|
||||
expect(source).toContain("function syncObjectListHeaderScroll(");
|
||||
expect(source).toContain("header.scrollLeft = el.scrollLeft");
|
||||
// The sync runs on every scroller scroll event, when the scroller (re)attaches,
|
||||
// and whenever the column widths change the content width.
|
||||
const scrollHandler = source.indexOf("function onObjectsScroll(");
|
||||
expect(source.slice(scrollHandler, scrollHandler + 300)).toContain("syncObjectListHeaderScroll()");
|
||||
expect(source).toContain("nextTick(() => syncObjectListHeaderScroll())");
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionBody(name: string): string {
|
||||
const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source);
|
||||
if (!signature) throw new Error(`Missing function ${name}`);
|
||||
const bodyStart = signature.index + signature[0].length;
|
||||
let depth = 1;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart, index);
|
||||
}
|
||||
throw new Error(`Unclosed function ${name}`);
|
||||
}
|
||||
|
||||
describe("ObjectBrowser XLSX export", () => {
|
||||
it("asks for export options before opening the save dialog", () => {
|
||||
const dialog = functionBody("showObjectBrowserXlsxHeaderDialog");
|
||||
const exportDataXlsx = functionBody("exportDataXlsx");
|
||||
|
||||
expect(dialog).toContain("showHeaderOptions: hasComments");
|
||||
expect(exportDataXlsx).toContain("hasXlsxHeaderComments(columnInfos?.map((column) => column.comment))");
|
||||
expect(exportDataXlsx.indexOf("await showObjectBrowserXlsxHeaderDialog(")).toBeLessThan(exportDataXlsx.indexOf('await exportTableData(row, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter)'));
|
||||
});
|
||||
|
||||
it("asks for the SQL INSERT mode before opening the save dialog", () => {
|
||||
const exportData = functionBody("exportData");
|
||||
const exportTableData = functionBody("exportTableData");
|
||||
|
||||
expect(exportData).toContain("await showSqlInsertModeDialog({ allowSplit: true })");
|
||||
expect(exportData.indexOf("await showSqlInsertModeDialog({ allowSplit: true })")).toBeLessThan(exportData.indexOf("await exportTableData("));
|
||||
expect(exportTableData).toContain('...(format === "sql" ? { insertMode, splitMaxMb } : {})');
|
||||
});
|
||||
|
||||
it("falls back to field-name headers when column metadata is unavailable", () => {
|
||||
const exportDataXlsx = functionBody("exportDataXlsx");
|
||||
|
||||
expect(exportDataXlsx).toContain("catch {\n // Export still works with field-name headers when column metadata is unavailable.\n }");
|
||||
expect(exportDataXlsx).toContain('await exportTableData(row, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter)');
|
||||
});
|
||||
});
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionBody(name: string): string {
|
||||
const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source);
|
||||
if (!signature) throw new Error(`Missing function ${name}`);
|
||||
const bodyStart = signature.index + signature[0].length;
|
||||
let depth = 1;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart, index);
|
||||
}
|
||||
throw new Error(`Unclosed function ${name}`);
|
||||
}
|
||||
|
||||
describe("ObjectBrowser table metadata refresh", () => {
|
||||
it("refreshes the object list and the open table-info tab from the toolbar", () => {
|
||||
const refresh = functionBody("refresh");
|
||||
|
||||
expect(refresh).toContain("void reload({ preserveExistingRows: true });");
|
||||
expect(refresh).toContain("void refreshActiveTableInfo();");
|
||||
expect(source).toContain('@click="refresh"');
|
||||
});
|
||||
|
||||
it("invalidates stale requests and reloads only the active metadata surface", () => {
|
||||
const refreshTableInfo = functionBody("refreshActiveTableInfo");
|
||||
|
||||
expect(refreshTableInfo).toContain('sidePanelMode.value !== "table-info" || !sidePanelRow.value');
|
||||
expect(refreshTableInfo).toContain("sidePanelGuard.bump();");
|
||||
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "ddl"[\s\S]*?rawTableDdlContent\.value = "";[\s\S]*?await fetchTableDdl\(true\);/);
|
||||
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "columns"[\s\S]*?tableColumns\.value = \[\];[\s\S]*?await fetchTableColumns\(true\);/);
|
||||
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "indexes"[\s\S]*?tableIndexes\.value = \[\];[\s\S]*?await fetchTableIndexes\(true\);/);
|
||||
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "foreignKeys"[\s\S]*?tableForeignKeys\.value = \[\];[\s\S]*?await fetchTableForeignKeys\(true\);/);
|
||||
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "triggers"[\s\S]*?tableTriggers\.value = \[\];[\s\S]*?await fetchTableTriggers\(true\);/);
|
||||
});
|
||||
|
||||
it("routes table-info metadata through the object caches", () => {
|
||||
expect(source).toContain('from "@/lib/metadata/objectDdlCache"');
|
||||
expect(source).toContain('from "@/lib/metadata/objectMetadataCache"');
|
||||
expect(functionBody("fetchTableDdl")).toContain("loadObjectDdl(");
|
||||
for (const name of ["fetchTableColumns", "fetchTableIndexes", "fetchTableForeignKeys", "fetchTableTriggers"]) {
|
||||
expect(functionBody(name)).toContain("loadObjectMetadataFacet(");
|
||||
expect(functionBody(name)).not.toContain(".value.length > 0");
|
||||
}
|
||||
});
|
||||
|
||||
it("shares the DDL refresh preference and exposes an explicit refresh action", () => {
|
||||
expect(source).toContain("async function fetchTableDdl(force = settingsStore.editorSettings.refreshDdlOnOpen)");
|
||||
expect(source).toContain('@click="refreshActiveTableInfo"');
|
||||
expect(source).not.toContain("setTableInfoRefreshDdlOnOpen");
|
||||
});
|
||||
|
||||
it("keeps automatic object reloads free of extra metadata requests", () => {
|
||||
expect(functionBody("reload")).not.toContain("refreshActiveTableInfo");
|
||||
});
|
||||
|
||||
it("leaves a failed facet eligible for retry", () => {
|
||||
for (const name of ["fetchTableDdl", "fetchTableColumns", "fetchTableIndexes", "fetchTableForeignKeys", "fetchTableTriggers"]) {
|
||||
const body = functionBody(name);
|
||||
expect(body).toContain("let loadedSuccessfully = false;");
|
||||
expect(body).toMatch(/loadedSuccessfully = true;/);
|
||||
expect(body).toMatch(/Loaded\.value = loadedSuccessfully;/);
|
||||
}
|
||||
});
|
||||
});
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionBody(name: string): string {
|
||||
const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source);
|
||||
if (!signature) throw new Error(`Missing function ${name}`);
|
||||
const bodyStart = signature.index + signature[0].length;
|
||||
let depth = 1;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart, index);
|
||||
}
|
||||
throw new Error(`Unclosed function ${name}`);
|
||||
}
|
||||
|
||||
describe("ObjectBrowser scaffold refresh race", () => {
|
||||
it("restores cached rows without revalidating on a tab remount", () => {
|
||||
const body = functionBody("loadObjects");
|
||||
|
||||
const cachedBranch = body.indexOf("const cached =");
|
||||
const cachedReturn = body.indexOf("finishOnce();\n return;", cachedBranch);
|
||||
|
||||
expect(cachedBranch).toBeGreaterThanOrEqual(0);
|
||||
expect(cachedReturn).toBeGreaterThan(cachedBranch);
|
||||
expect(body.slice(cachedBranch, cachedReturn)).not.toContain("cached.stale");
|
||||
expect(body.slice(cachedBranch, cachedReturn)).not.toContain("refreshingObjects.value = true;");
|
||||
});
|
||||
|
||||
it("exposes a non-blocking refresh flag distinct from the blocking full-area load", () => {
|
||||
expect(source).toContain("const refreshingObjects = ref(false);");
|
||||
expect(functionBody("loadObjects")).toContain("refreshingObjects.value = true;");
|
||||
expect(functionBody("loadObjects")).toContain("loadingObjects.value = true;");
|
||||
});
|
||||
|
||||
it("keeps visible rows on a background revalidate failure (scaffold-preserving error)", () => {
|
||||
expect(source).toContain('const scaffoldRefreshError = ref("");');
|
||||
const body = functionBody("loadObjects");
|
||||
// A scaffold/refresh revalidate failure must route to the non-blocking banner
|
||||
// (scaffoldRefreshError) and keep the visible rows, not replace them with the
|
||||
// blocking full-area error.
|
||||
expect(body).toContain("scaffoldRefresh = true;");
|
||||
expect(body).toContain("if (scaffoldRefresh) {");
|
||||
expect(body).toContain("scaffoldRefreshError.value = translateBackendError(t, e)");
|
||||
expect(source).toContain('v-if="scaffoldRefreshError"');
|
||||
});
|
||||
});
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Issues #8236 and #8285: switching away from the "Browse Objects" tab and
|
||||
// back reset the keyword search, including after opening and closing a table,
|
||||
// because ObjectBrowser has no <KeepAlive> around
|
||||
// it (ContentArea renders it inside a plain v-else-if chain) and its `search`
|
||||
// ref was always seeded to "". The fix threads the value through
|
||||
// activeTab.objectBrowser.searchQuery, mirroring the existing viewport
|
||||
// persistence path.
|
||||
|
||||
const objectBrowserSource = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
const contentAreaSource = readFileSync(new URL("../../layout/ContentArea.vue", import.meta.url), "utf8");
|
||||
const querySurfacesSource = readFileSync(new URL("../../layout/querySurfaces.ts", import.meta.url), "utf8");
|
||||
const contentSurfaceEventsSource = readFileSync(new URL("../../../lib/tabs/contentSurfaceEvents.ts", import.meta.url), "utf8");
|
||||
const queryStoreSource = readFileSync(new URL("../../../stores/queryStore.ts", import.meta.url), "utf8");
|
||||
const databaseTypesSource = readFileSync(new URL("../../../types/database.ts", import.meta.url), "utf8");
|
||||
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ObjectBrowser keyword search survives tab switches", () => {
|
||||
it("seeds the local search ref from a persisted initial value instead of always starting empty", () => {
|
||||
expect(objectBrowserSource).toContain("initialSearchQuery?: string;");
|
||||
expect(objectBrowserSource).toContain('const search = ref(props.initialSearchQuery ?? "");');
|
||||
});
|
||||
|
||||
it("emits searchChange whenever the keyword changes, so the parent can persist it", () => {
|
||||
expect(objectBrowserSource).toContain("searchChange: [query: string];");
|
||||
expect(objectBrowserSource).toMatch(/watch\(search, \(value\) => \{\s*scrollObjectsToTop\(\);\s*emit\("searchChange", value\);\s*\}\);/);
|
||||
});
|
||||
|
||||
it("ContentArea binds the persisted search query as a prop and forwards the change event", () => {
|
||||
expect(contentAreaSource).toContain(':initial-search-query="activeTab.objectBrowser?.searchQuery"');
|
||||
expect(contentAreaSource).toContain("@search-change=\"emit('objectBrowserSearchChange', activeTab.id, $event)\"");
|
||||
});
|
||||
|
||||
it("keeps the original object tab as the owner when a table is opened", () => {
|
||||
expect(appSource).toMatch(/@open-object-table="\s*\n?\s*\(tabId: string, target: \{[\s\S]*?\) => \{[\s\S]*?const tab = queryStore\.tabs\.find\(\(candidate\) => candidate\.id === tabId\) \?\? activeTab;[\s\S]*?openObjectBrowserTableTarget\(/);
|
||||
expect(appSource).toContain('@object-browser-search-change="(tabId: string, query: string) => queryStore.updateObjectBrowserSearch(tabId, query)"');
|
||||
});
|
||||
|
||||
it("the surface event contract and forwarding list both know about objectBrowserSearchChange", () => {
|
||||
expect(querySurfacesSource).toContain("objectBrowserSearchChange: [tabId: string, query: string];");
|
||||
expect(contentSurfaceEventsSource).toContain('"objectBrowserSearchChange",');
|
||||
});
|
||||
|
||||
it("queryStore persists the search query onto the tab's objectBrowser state", () => {
|
||||
expect(queryStoreSource).toContain("function updateObjectBrowserSearch(id: string, query: string)");
|
||||
expect(queryStoreSource).toContain("tab.objectBrowser = { ...tab.objectBrowser, searchQuery: query };");
|
||||
expect(queryStoreSource).toContain("updateObjectBrowserSearch,");
|
||||
});
|
||||
|
||||
it("the tab type declares searchQuery on objectBrowser state", () => {
|
||||
expect(databaseTypesSource).toMatch(/objectBrowser\?:\s*\{[^}]*searchQuery\?:\s*string;/s);
|
||||
});
|
||||
});
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionBody(name: string): string {
|
||||
const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source);
|
||||
if (!signature) throw new Error(`Missing function ${name}`);
|
||||
const bodyStart = signature.index + signature[0].length;
|
||||
let depth = 1;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart, index);
|
||||
}
|
||||
throw new Error(`Unclosed function ${name}`);
|
||||
}
|
||||
|
||||
describe("ObjectBrowser viewport persistence", () => {
|
||||
it("flushes the latest scroll position before generating SQL", () => {
|
||||
const body = functionBody("openNewQuery");
|
||||
const flushIndex = body.indexOf("flushObjectBrowserViewport();");
|
||||
const createTabIndex = body.indexOf("queryStore.createTab");
|
||||
|
||||
expect(flushIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(createTabIndex).toBeGreaterThan(flushIndex);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@
|
||||
import { createApp, nextTick, ref, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
import pluginHostSource from "./PluginWorkbenchHost.vue?raw";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readPluginUiEntry: vi.fn(),
|
||||
@@ -198,17 +197,3 @@ describe("PluginWorkbenchHost initialization", () => {
|
||||
elementFromPoint.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginWorkbenchHost file-save handle tracking", () => {
|
||||
const hostSource = pluginHostSource;
|
||||
const beginSaveSource = hostSource.slice(hostSource.indexOf("async function beginPluginFileSave"), hostSource.indexOf("async function writePluginFileChunkById"));
|
||||
|
||||
it("opens the save target through openTauriPluginFile so the write handle joins openTauriHandles", () => {
|
||||
// A beginSave the plugin abandons (no finish/cancel) must still be
|
||||
// reclaimed by unmount's disposeLocalFileHandles; a direct
|
||||
// openPluginLocalFile call would leak the handle in the shared
|
||||
// 64-slot registry for the lifetime of the workbench host.
|
||||
expect(beginSaveSource).toContain("await openTauriPluginFile(pluginId, path, true)");
|
||||
expect(beginSaveSource).not.toContain("openPluginLocalFile");
|
||||
});
|
||||
});
|
||||
|
||||
-6
@@ -1,7 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, type App, type ComponentPublicInstance } from "vue";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database";
|
||||
import type { MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
|
||||
@@ -494,11 +493,6 @@ function singleApi(single: Single) {
|
||||
const replacements = ["marketplace", "rollback", "package", "url"] as const;
|
||||
|
||||
describe("PluginContributionsPanel workbench refresh", () => {
|
||||
it("connects the panel event to the App workbench refresh entry", () => {
|
||||
const source = readFileSync("apps/desktop/src/App.vue", "utf8");
|
||||
expect(source).toMatch(/<PluginCenterPage\b[^>]*@plugin-runtime-replaced="refreshPluginWorkbenches"/);
|
||||
});
|
||||
|
||||
it.each(replacements)("refreshes once with the returned plugin ID after Web %s succeeds", async (single) => {
|
||||
singleApi(single).mockResolvedValueOnce({ plugin: installed("replaced", "2.0.0") });
|
||||
await startSingle(single);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import source from "./RedisKeyBrowser.vue?raw";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("uses the fuzzy toggle visual states while preserving grouping persistence and busy guards", () => {
|
||||
const button = source.match(/<Button\b[^>]*data-redis-grouping-toggle[^>]*>/s)?.[0];
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toContain("customGrouping.enabled ? 'bg-accent text-accent-foreground' : 'border border-dashed border-border/70 text-muted-foreground hover:text-foreground'");
|
||||
expect(button).toContain(':aria-pressed="customGrouping.enabled"');
|
||||
expect(button).toContain(':disabled="groupingSaving || isFetchingAll"');
|
||||
expect(button).toContain("saveGrouping({ ...customGrouping, enabled: !customGrouping.enabled })");
|
||||
});
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// jsdom cannot lay out flex/grid or evaluate container queries, so the #9356
|
||||
// overlap fix is guarded at the source level: the toolbar actions group must
|
||||
// wrap (a nowrap + justify-end row spills LEFT onto the search-mode segmented
|
||||
// control when the key pane is ~320px..730px wide), and the batch-expiry label
|
||||
// must collapse to an icon before the row reaches that overflow band.
|
||||
const source = readFileSync(new URL("../RedisKeyBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
function styleRule(selector: string): string {
|
||||
// Anchor on a line-start selector so template `class="..."` occurrences never match.
|
||||
const start = source.indexOf(`\n${selector} {`);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const open = source.indexOf("{", start);
|
||||
const close = source.indexOf("}", open);
|
||||
return source.slice(open + 1, close);
|
||||
}
|
||||
|
||||
describe("redis key browser toolbar layout (#9356)", () => {
|
||||
it("lets the toolbar actions wrap instead of overflowing onto the search-mode group", () => {
|
||||
expect(styleRule(".redis-key-toolbar-actions")).toContain("flex-wrap: wrap");
|
||||
});
|
||||
|
||||
it("collapses the batch-expiry label to an icon in a 740px container tier", () => {
|
||||
const tierStart = source.indexOf("@container (max-width: 740px)");
|
||||
const tierEnd = source.indexOf("@container (max-width: 340px)");
|
||||
expect(tierStart).toBeGreaterThan(-1);
|
||||
expect(tierEnd).toBeGreaterThan(tierStart);
|
||||
const tierBody = source.slice(tierStart, tierEnd);
|
||||
expect(tierBody).toContain(".redis-expiry-label");
|
||||
expect(tierBody).toContain(".redis-expiry-icon");
|
||||
});
|
||||
|
||||
it("switches to two toolbar rows in the 340px container tier", () => {
|
||||
const tierStart = source.indexOf("@container (max-width: 340px)");
|
||||
const tierEnd = source.indexOf("@container (max-width: 240px)");
|
||||
expect(tierStart).toBeGreaterThan(-1);
|
||||
expect(tierEnd).toBeGreaterThan(tierStart);
|
||||
const tierBody = source.slice(tierStart, tierEnd);
|
||||
expect(tierBody).toContain("grid-template-columns: auto minmax(0, 1fr)");
|
||||
const actionsRule = tierBody.slice(tierBody.indexOf(".redis-key-toolbar-actions"));
|
||||
expect(actionsRule).toContain("grid-row: 2");
|
||||
expect(actionsRule).toContain("justify-content: flex-start");
|
||||
});
|
||||
|
||||
it("marks the batch-expiry icon and label in the template", () => {
|
||||
expect(source).toMatch(/class="[^"]*redis-expiry-icon[^"]*"/);
|
||||
expect(source).toMatch(/<span class="redis-expiry-label">/);
|
||||
expect(source).toContain(`:aria-label="t('redis.batchExpiry')"`);
|
||||
});
|
||||
|
||||
it("keeps the key pane at a 360px minimum width", () => {
|
||||
expect(source).toContain("min-width: min(360px, 64%)");
|
||||
});
|
||||
});
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const connectionTreeSource = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8");
|
||||
|
||||
function focusSidebarTreeNodeBody(): string {
|
||||
const match = connectionTreeSource.match(/^async function focusSidebarTreeNode[\s\S]*?^}/m);
|
||||
expect(match).not.toBeNull();
|
||||
return match![0]!;
|
||||
}
|
||||
|
||||
describe("ConnectionTree arrow focus", () => {
|
||||
it("scrolls before querying the row so out-of-window virtual rows materialize first", () => {
|
||||
const body = focusSidebarTreeNodeBody();
|
||||
const scroll = body.indexOf("await scrollToSidebarNode(nodeId);");
|
||||
const renderWait = body.indexOf("await waitForSidebarRenderFrame();");
|
||||
const query = body.indexOf('root.querySelector<HTMLElement>(`[data-node-id="${CSS.escape(nodeId)}"]`)');
|
||||
const missingRowGuard = body.indexOf("if (!row) return;");
|
||||
const focus = body.indexOf("row.focus({ preventScroll: true });");
|
||||
|
||||
// RecycleScroller only keeps the materialized window in the DOM, so the
|
||||
// index-driven scroll must run (no-op when already visible) and one render
|
||||
// frame must pass before the row can be queried and focused.
|
||||
for (const position of [scroll, renderWait, query, missingRowGuard, focus]) expect(position).toBeGreaterThan(-1);
|
||||
expect(scroll).toBeLessThan(renderWait);
|
||||
expect(renderWait).toBeLessThan(query);
|
||||
expect(query).toBeLessThan(missingRowGuard);
|
||||
expect(missingRowGuard).toBeLessThan(focus);
|
||||
});
|
||||
|
||||
it("keeps focus a no-op when the row is still not rendered after the scroll", () => {
|
||||
const body = focusSidebarTreeNodeBody();
|
||||
expect(body).toContain("if (!row) return;");
|
||||
});
|
||||
});
|
||||
-43
@@ -1,50 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runSidebarSearchTasks } from "../sidebarSearchTaskRunner";
|
||||
|
||||
const source = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ConnectionTree global search loading", () => {
|
||||
it("discovers collapsed database and schema containers before searching object groups", () => {
|
||||
expect(source).toContain("function isSidebarSearchContainer(node: TreeNode)");
|
||||
// A collapsed database already holds the saved-SQL root, so it has children
|
||||
// even when its object groups were never fetched; the search has to look for
|
||||
// a searchable group instead of an empty child list.
|
||||
expect(source).toMatch(/isSidebarSearchContainer\(node\) && needsSidebarObjectGroupDiscovery\(node, searchableObjectGroupTypes\)/);
|
||||
expect(source).not.toMatch(/isSidebarSearchContainer\(node\) && !node\.children\?\.length/);
|
||||
expect(source).toContain("store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery, sidebarSearch: true })");
|
||||
expect(source).toContain("searchExpansionState.markFiltered(node.id, wasCollapsed)");
|
||||
expect(source).toMatch(/if \(refreshedNodeIds && node\.children\) \{[\s\S]*?searchableObjectGroupTypes\.has\(child\.type\)/);
|
||||
});
|
||||
|
||||
it("deduplicates each node within a search round and restores tracked nodes", () => {
|
||||
expect(source).toContain("const scheduledNodeIds = new Set<string>();");
|
||||
expect(source).toContain("while (deferredSearchQuery.value === query && store.sidebarSearchQuery === query)");
|
||||
expect(source).toContain("const restoreTasks = !newQuery && oldQuery ? restoreTrackedSearchTargets() : [];");
|
||||
expect(source).toContain("if (shouldCollapse) node.isExpanded = false;");
|
||||
expect(source).toContain("store.discardFilteredTreeNodeChildren(node.id);");
|
||||
});
|
||||
|
||||
it("restores the pre-search children of an expanded group instead of reloading page one", () => {
|
||||
// Clearing a remote search must put back the pages the user had loaded
|
||||
// through "load more"; only an uncaptured group falls back to a reload.
|
||||
expect(source).toContain("} else if (!store.restoreFilteredObjectGroupChildren(node)) {");
|
||||
expect(source).toMatch(/restoreFilteredObjectGroupChildren\(node\)\) \{[\s\S]*?tasks\.push\(\(\) => store\.loadObjectGroupChildren\(node, \{ force: true, sidebarSearch: true \}\)\);/);
|
||||
});
|
||||
|
||||
it("never lets the global search connect a connection on the user's behalf", () => {
|
||||
// Searching the sidebar is a background projection. Reconnecting here is what
|
||||
// surfaced a raw SQL Server TLS/login error for a connection whose saved
|
||||
// database no longer exists, so only already-connected connections may be
|
||||
// refreshed and every search-driven load is tagged for the store.
|
||||
expect(source).toContain("const connectionIsConnected = store.connectedIds.has(node.connectionId);");
|
||||
expect(source).toMatch(/if \(!connectionIsConnected \|\| node\.connectionId !== store\.activeConnectionId\) return;/);
|
||||
// Search-driven loads are tagged so the store degrades a failed reconnect to a
|
||||
// one-line skip hint instead of the raw driver error (see the store spec).
|
||||
expect(source.match(/sidebarSearch: true/g)?.length ?? 0).toBeGreaterThanOrEqual(7);
|
||||
expect(source).not.toMatch(/tasks\.push\(\(\) => store\.connect\(/);
|
||||
});
|
||||
|
||||
it("limits concurrent metadata loads without dropping a task", async () => {
|
||||
let activeTasks = 0;
|
||||
let maximumActiveTasks = 0;
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const connectionTreeSource = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ConnectionTree locate in collapsed groups", () => {
|
||||
it("reopens collapsed connection groups on the node path through the persisted layout op", () => {
|
||||
// Group expansion must flow through store.expandConnectionGroups so
|
||||
// layout.collapsed is flipped and the next layout rebuild keeps the group
|
||||
// open; flipping isExpanded directly would be reverted by that rebuild.
|
||||
expect(connectionTreeSource).toMatch(/const collapsedGroupIds = nodePath\s*\.filter\(\(node\) => node\.type === "connection-group" && !node\.isExpanded\)\s*\.map\(\(node\) => node\.id\);/);
|
||||
// The expansion happens before the flat-tree match that drives selection,
|
||||
// scrolling and flashing, so the reopened groups are visible to it.
|
||||
expect(connectionTreeSource).toMatch(/store\.expandConnectionGroups\(collapsedGroupIds\);\s*\}\s*\n\s*await nextTick\(\);\s*\n\s*const match = target \? findSidebarNodeForTarget\(target, flatNodes\.value\) : null;/);
|
||||
});
|
||||
|
||||
it("still gates generic ancestor expansion on loaded children only (#5850)", () => {
|
||||
expect(connectionTreeSource).toMatch(/if \(!ancestor\.isExpanded && store\.canUseLoadedTreeNodeToggle\(ancestor\)\) \{\s*ancestor\.isExpanded = true;\s*\}/);
|
||||
});
|
||||
});
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const connectionTreeSource = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ConnectionTree selection pruning", () => {
|
||||
it("does not track the visible tree outside multi-selection", () => {
|
||||
expect(connectionTreeSource).toContain("store.connectionMultiSelectActive || store.selectedTreeNodeIds.length > 1");
|
||||
expect(connectionTreeSource).toContain("hasTreeMultiSelection.value ? flatTreeIndex.value.nodeById : null");
|
||||
expect(connectionTreeSource).not.toMatch(/watch\(\s*visibleNodes,/);
|
||||
});
|
||||
|
||||
it("defers selection cleanup until after the tree render", () => {
|
||||
expect(connectionTreeSource).toMatch(/pruneTreeSelectionToVisibleNodeIds[\s\S]*?\{ flush: "post" \}/);
|
||||
});
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("SidebarTreeRuntimeHost database backup menu", () => {
|
||||
it("uses the database backup support matrix independently from full-database export", () => {
|
||||
expect(source).toMatch(/const canOpenScheduledBackups = computed\(\(\) => \{[\s\S]*supportsScheduledDatabaseBackup\(rawDatabaseType\(\)\)/);
|
||||
expect(source).toMatch(/if \(canOpenScheduledBackups\.value\) \{[\s\S]*action: openScheduledBackups/);
|
||||
});
|
||||
});
|
||||
Vendored
-28
@@ -1,28 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionSource(name: string, nextName: string) {
|
||||
const start = source.indexOf(`async function ${name}`);
|
||||
const end = source.indexOf(`async function ${nextName}`, start);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
describe("SidebarTreeRuntimeHost compile refresh routing", () => {
|
||||
it("refreshes the Dameng schema object list after compiling a view", () => {
|
||||
const compile = functionSource("compileDamengView", "executeXuguSchedulerJobAction");
|
||||
|
||||
expect(compile).toContain("await connectionStore.refreshObjectListTreeNode(node.connectionId, node.database, node.schema);");
|
||||
expect(compile).not.toContain("await connectionStore.refreshTreeNode(node);");
|
||||
});
|
||||
|
||||
it("keeps Xugu compile refresh behavior unchanged", () => {
|
||||
const compile = functionSource("compileXuguObject", "compileDamengView");
|
||||
|
||||
expect(compile).toContain("await connectionStore.refreshTreeNode(node);");
|
||||
expect(compile).not.toContain("await connectionStore.refreshObjectListTreeNode");
|
||||
});
|
||||
});
|
||||
Vendored
-27
@@ -1,27 +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");
|
||||
|
||||
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("SidebarTreeRuntimeHost createView identifier quoting (#9099)", () => {
|
||||
it("forwards the generate-SQL settings to qualifiedTableName", () => {
|
||||
const createView = functionSource("createView");
|
||||
const callStart = createView.indexOf("qualifiedTableName({");
|
||||
expect(callStart).toBeGreaterThan(-1);
|
||||
const callEnd = createView.indexOf("});", callStart);
|
||||
const call = createView.slice(callStart, callEnd);
|
||||
|
||||
// Turning off the quote toggle must reach qualifiedTableName; otherwise it
|
||||
// unconditionally quotes the schema/view name regardless of the setting.
|
||||
expect(call).toContain("quoteIdentifiers: settingsStore.editorSettings.generateSqlQuoteIdentifiers");
|
||||
expect(call).toContain("includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName");
|
||||
});
|
||||
});
|
||||
Vendored
-22
@@ -1,22 +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");
|
||||
|
||||
function asyncFunctionSource(name: string): string {
|
||||
const start = runtimeSource.indexOf(`async function ${name}(`);
|
||||
expect(start, `${name} not found`).toBeGreaterThan(-1);
|
||||
const next = runtimeSource.indexOf("\nasync function ", start + 1);
|
||||
return runtimeSource.slice(start, next === -1 ? undefined : next);
|
||||
}
|
||||
|
||||
describe("SidebarTreeRuntimeHost DDL database qualifier setting (#9421)", () => {
|
||||
it("applies the setting with each target catalog before identifier quote formatting", () => {
|
||||
const openDdl = asyncFunctionSource("openSidebarMultiTableDdlTab");
|
||||
const formatterCall = openDdl.indexOf("formatSidebarDdlTemplateForDisplay(");
|
||||
|
||||
expect(formatterCall).toBeGreaterThan(-1);
|
||||
expect(openDdl.slice(formatterCall, openDdl.indexOf(");", formatterCall))).toContain("target.catalog");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user