test: drop source-text assertion tests and merge locale checks
This commit is contained in:
1 parent
349d77d180
commit
8897e12714
257 files changed
+166
-12815
No files matched your search
-24
@@ -1,24 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
const tauriBackendSource = readFileSync("apps/desktop/src/lib/backend/tauri.ts", "utf8");
|
||||
const tauriAiCommandSource = readFileSync("src-tauri/src/commands/ai.rs", "utf8");
|
||||
|
||||
test("Tauri AI agent stream events carry their session id", () => {
|
||||
assert.match(tauriAiCommandSource, /struct AiAgentEventPayload \{/);
|
||||
assert.match(tauriAiCommandSource, /session_id: String/);
|
||||
assert.match(tauriAiCommandSource, /#\[serde\(flatten\)\]\s*event: AgentEvent/);
|
||||
assert.match(
|
||||
tauriAiCommandSource,
|
||||
/AiAgentEventPayload \{ session_id: self\.session_id\.clone\(\), event \}/,
|
||||
);
|
||||
assert.match(tauriAiCommandSource, /app\.emit\("ai-agent-event", &payload\)/);
|
||||
});
|
||||
|
||||
test("frontend ignores AI agent stream events from other sessions", () => {
|
||||
assert.match(tauriBackendSource, /type TauriAgentEvent = AgentEvent & \{\s*session_id\?: string;\s*\};/);
|
||||
assert.match(tauriBackendSource, /listen<TauriAgentEvent>\("ai-agent-event"/);
|
||||
assert.match(tauriBackendSource, /if \(payload\.session_id && payload\.session_id !== sessionId\) return;/);
|
||||
assert.match(tauriBackendSource, /onEvent\(payload\);/);
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "vitest";
|
||||
import { compileTemplate, parse } from "vue/compiler-sfc";
|
||||
|
||||
const aiAssistantPath = fileURLToPath(new URL("../../apps/desktop/src/components/editor/AiAssistant.vue", import.meta.url));
|
||||
const zhCnLocalePath = fileURLToPath(new URL("../../apps/desktop/src/i18n/locales/zh-CN.ts", import.meta.url));
|
||||
const source = readFileSync(aiAssistantPath, "utf8");
|
||||
const zhCnLocaleSource = readFileSync(zhCnLocalePath, "utf8");
|
||||
|
||||
test("AI composer keeps templates available without connections outside plugin conversations", () => {
|
||||
const contextRowStart = source.indexOf("data-ai-composer-context-row");
|
||||
const contextRowEnd = source.indexOf('v-if="mentionOpen"', contextRowStart);
|
||||
const contextRow = source.slice(contextRowStart, contextRowEnd);
|
||||
|
||||
assert.notEqual(contextRowStart, -1, "the composer context row should exist");
|
||||
assert.notEqual(contextRowEnd, -1, "the connection context row should end before mention suggestions");
|
||||
assert.match(contextRow, /<details v-if="pluginContext"[\s\S]*?<template v-else-if="connectionStore\.connections\.length">/);
|
||||
assert.match(contextRow, /<Popover v-if="!pluginContext" v-model:open="showTemplateSelector">/);
|
||||
assert.match(contextRow, /max-w-\[40%\]/);
|
||||
assert.match(contextRow, /<span class="ai-template-selector-label truncate">\{\{ templateSelectorTriggerLabel \}\}<\/span>/);
|
||||
assert.match(contextRow, /:aria-label="templateSelectorTriggerLabel"/);
|
||||
assert.doesNotMatch(contextRow, /ai-prompt-context-break/);
|
||||
assert.match(source, /\.ai-prompt-context-row--schema \.ai-template-selector-trigger \{[\s\S]*?flex: 0 0 1\.5rem;[\s\S]*?max-width: 1\.5rem;/);
|
||||
});
|
||||
|
||||
test("AI composer labels an empty template selection explicitly", () => {
|
||||
assert.match(source, /const templateSelectorTriggerLabel = computed\(\(\) => \{[\s\S]*?templateSelectorLabel.*templateSelectorLabel\.value/);
|
||||
assert.match(zhCnLocaleSource, /templateSelectorNone: "未选择"/);
|
||||
});
|
||||
|
||||
test("AI composer exposes mode and action as one compact selector", () => {
|
||||
const footerStart = source.indexOf("<!-- Combined mode + action selector -->");
|
||||
const footerEnd = source.indexOf("<!-- Combined provider + model selector -->", footerStart);
|
||||
const footer = source.slice(footerStart, footerEnd);
|
||||
|
||||
assert.notEqual(footerStart, -1, "the combined mode and action selector should exist");
|
||||
assert.notEqual(footerEnd, -1, "the model selector should follow the combined selector");
|
||||
assert.match(footer, /<span v-if="pluginContext"[\s\S]*?<Popover v-else v-model:open="modeActionOpen">/);
|
||||
assert.match(footer, /:aria-label="modeActionTriggerLabel"/);
|
||||
assert.match(footer, /switchModeActionTab\('ask'\)/);
|
||||
assert.match(footer, /switchModeActionTab\('agent'\)/);
|
||||
assert.match(footer, /selectModeActionItem\(button\.action\)/);
|
||||
assert.doesNotMatch(footer, /selectAction\(button\.action\)/);
|
||||
assert.match(footer, /<template v-if="showActionButtons">[\s\S]*?<div class="border-t my-1" \/>[\s\S]*?v-for="button in actionButtons"/);
|
||||
assert.match(source, /function selectModeActionItem\(action: AiActionSelection\) \{\s*\/\/ Vector databases[\s\S]*?if \(!showActionButtons\.value\) return;/);
|
||||
});
|
||||
|
||||
test("AI effort control opens as a hoverable side submenu", () => {
|
||||
const selectorStart = source.indexOf("<!-- Combined provider + model selector -->");
|
||||
const selectorEnd = source.indexOf("</template>", source.indexOf("</Popover>", selectorStart));
|
||||
const selector = source.slice(selectorStart, selectorEnd);
|
||||
|
||||
assert.notEqual(selectorStart, -1, "the combined provider and model selector should exist");
|
||||
assert.match(selector, /<Popover v-model:open="effortMenuOpen">/);
|
||||
assert.match(selector, /<PopoverAnchor as-child>/);
|
||||
assert.match(selector, /@mouseenter="openEffortMenu"/);
|
||||
assert.match(selector, /@mouseleave="scheduleEffortMenuClose"/);
|
||||
assert.match(selector, /<PopoverContent[\s\S]*?side="left"[\s\S]*?:side-offset="6"/);
|
||||
assert.doesNotMatch(selector, /v-if="effortPanelOpen"/);
|
||||
});
|
||||
|
||||
test("AI model menu separates provider groups from model selections", () => {
|
||||
const selectorStart = source.indexOf("<!-- Combined provider + model selector -->");
|
||||
const selectorEnd = source.indexOf("</template>", source.indexOf("</Popover>", selectorStart));
|
||||
const selector = source.slice(selectorStart, selectorEnd);
|
||||
|
||||
assert.match(selector, /<div v-for="\(config, configIndex\) in configuredProviders" :key="config\.id" class="relative">/);
|
||||
assert.match(selector, /class="sticky top-0 z-10 flex w-full items-center gap-2 rounded-sm bg-popover/);
|
||||
assert.match(selector, /class="ml-5 border-l border-border\/60 pl-1"/);
|
||||
assert.match(selector, /v-if="configIndex < configuredProviders\.length - 1" class="my-1 border-t"/);
|
||||
assert.doesNotMatch(selector, /bg-accent text-accent-foreground.*config\.id === settings\.activeModel/);
|
||||
});
|
||||
|
||||
test("AI model and effort menu refreshes do not persist effort settings", () => {
|
||||
const loaderStart = source.indexOf("async function ensureModelEffort");
|
||||
const loaderEnd = source.indexOf("function handleModelSelect", loaderStart);
|
||||
const loader = source.slice(loaderStart, loaderEnd);
|
||||
|
||||
assert.notEqual(loaderStart, -1, "the effort capability loader should exist");
|
||||
assert.notEqual(loaderEnd, -1, "the model selection handler should follow the effort loader");
|
||||
assert.match(loader, /await resolveEffort\(config, modelId, force\)/);
|
||||
assert.doesNotMatch(loader, /updateActiveEffort|persistAiChatSelection/);
|
||||
});
|
||||
|
||||
test("AI composer template remains compilable", () => {
|
||||
const { descriptor, errors } = parse(source, { filename: aiAssistantPath });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(descriptor.template);
|
||||
|
||||
const result = compileTemplate({ id: aiAssistantPath, filename: aiAssistantPath, source: descriptor.template.content });
|
||||
assert.deepEqual(result.errors, []);
|
||||
});
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
|
||||
// Regression guard for the F2 finding: AiAssistant.send() must acquire the
|
||||
// synchronous send guard (isGenerating.value = true) BEFORE its first await
|
||||
// (promptTemplateStore.ensureLoaded()). Otherwise two rapid submissions can
|
||||
// both pass the initial isGenerating check, await the deferred store load, and
|
||||
// resume into two concurrent agent runs.
|
||||
//
|
||||
// send() is an internal <script setup> function (not exported / not exposed),
|
||||
// and this repo has no component-mount test harness (@vue/test-utils is not a
|
||||
// dependency; zero specs call mount()). So — like aiMessageLayout.test.ts — we
|
||||
// assert the invariant against the SFC source: the guard is set synchronously
|
||||
// before any suspension point, which is exactly what makes the check-then-set
|
||||
// sequence atomic against a concurrent caller.
|
||||
|
||||
const aiAssistantPath = "apps/desktop/src/components/editor/AiAssistant.vue";
|
||||
const source = readFileSync(aiAssistantPath, "utf8");
|
||||
|
||||
function scriptSetupContent(): string {
|
||||
const { descriptor, errors } = parse(source, { filename: aiAssistantPath });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(descriptor.scriptSetup, "AiAssistant.vue should use <script setup>");
|
||||
return descriptor.scriptSetup!.content;
|
||||
}
|
||||
|
||||
// Extract a function body by brace-matching from a signature anchor, so the
|
||||
// assertions below are scoped to send() and immune to unrelated edits elsewhere
|
||||
// in the (very large) component.
|
||||
function functionBody(script: string, signature: string): string {
|
||||
const sigIndex = script.indexOf(signature);
|
||||
assert.notEqual(sigIndex, -1, `expected to find ${signature}`);
|
||||
assert.equal(script.indexOf(signature, sigIndex + signature.length), -1, `${signature} should be unique`);
|
||||
const openBrace = script.indexOf("{", sigIndex + signature.length);
|
||||
assert.notEqual(openBrace, -1, "function body should open with a brace");
|
||||
|
||||
let depth = 0;
|
||||
for (let i = openBrace; i < script.length; i++) {
|
||||
const ch = script[i];
|
||||
if (ch === "{") depth++;
|
||||
else if (ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return script.slice(openBrace + 1, i);
|
||||
}
|
||||
}
|
||||
throw new Error("unbalanced braces while extracting function body");
|
||||
}
|
||||
|
||||
test("send() acquires the guard synchronously before the first await", () => {
|
||||
const body = functionBody(scriptSetupContent(), "async function send()");
|
||||
|
||||
// Anchor on the condition, not on a one-line `return`: the branch may run a
|
||||
// synchronous cleanup first (a declined write-confirmation grant), so the
|
||||
// shape is a block now. The assertions below still require a return inside it.
|
||||
const entryCheck = body.search(/\|\|\s*isGenerating\.value\)\s*\{/);
|
||||
const guardSet = body.indexOf("isGenerating.value = true");
|
||||
// `await` as a whole word (not the substring inside e.g. the
|
||||
// `awaiting_write_confirmation` run status), so the first suspension point is
|
||||
// genuinely the first await statement.
|
||||
const firstAwait = body.search(/\bawait\b/);
|
||||
|
||||
assert.notEqual(entryCheck, -1, "send() should early-return when isGenerating is already set");
|
||||
assert.notEqual(guardSet, -1, "send() should set the isGenerating guard");
|
||||
assert.notEqual(firstAwait, -1, "send() should contain an await");
|
||||
assert.ok(body.slice(entryCheck, guardSet).includes("return"), "the isGenerating branch must return before the guard set");
|
||||
|
||||
// The entry check runs first, then the guard is set, then (and only then) the
|
||||
// first await suspends. Because nothing awaits between the check and the set,
|
||||
// a second concurrent send() cannot slip past the check before the guard flips.
|
||||
assert.ok(entryCheck < guardSet, "isGenerating check must precede the guard set");
|
||||
assert.ok(guardSet < firstAwait, "guard must be set before the first await (no await between check and set)");
|
||||
|
||||
// The first suspension point is the send-time skill read or the prompt-template
|
||||
// load — both run after the guard and both are pipeline-entry awaits whose
|
||||
// failure paths release the guard state explicitly (see the early returns
|
||||
// after `aiGenerationGuard.begin()`).
|
||||
const ensureLoadedAwait = body.indexOf("await promptTemplateStore.ensureLoaded()");
|
||||
assert.notEqual(ensureLoadedAwait, -1, "send() should await promptTemplateStore.ensureLoaded()");
|
||||
const skillReadAwait = body.indexOf("await readUserSkills([...selectedSkillIds.value], skillRootSettings())");
|
||||
const pipelineEntryAwaits = [ensureLoadedAwait, skillReadAwait].filter((index) => index >= 0);
|
||||
assert.ok(pipelineEntryAwaits.includes(firstAwait), "the first await in send() must be the skill read or the prompt-template load");
|
||||
});
|
||||
|
||||
test("send() never leaks the guard on an early return after acquiring it", () => {
|
||||
const body = functionBody(scriptSetupContent(), "async function send()");
|
||||
const guardSet = body.indexOf("isGenerating.value = true");
|
||||
|
||||
// Every `return` that appears after the guard is acquired must first reset the
|
||||
// guard (there is one such path: the ensureLoaded() failure branch). A bare
|
||||
// `return` after the guard without a reset would strand isGenerating at true
|
||||
// and permanently block all future sends.
|
||||
const tail = body.slice(guardSet);
|
||||
const returnsAfterGuard = [...tail.matchAll(/\breturn\b/g)];
|
||||
assert.ok(returnsAfterGuard.length >= 1, "expected the ensureLoaded failure branch to early-return");
|
||||
|
||||
for (const match of returnsAfterGuard) {
|
||||
const preceding = tail.slice(0, match.index);
|
||||
assert.ok(preceding.includes("isGenerating.value = false"), "any early return after the guard is acquired must reset isGenerating first");
|
||||
}
|
||||
});
|
||||
|
||||
test("send() snapshots custom prompts before deferred AI context loading", () => {
|
||||
const body = functionBody(scriptSetupContent(), "async function send()");
|
||||
const ensureLoadedAwait = body.indexOf("await promptTemplateStore.ensureLoaded()");
|
||||
const snapshot = body.indexOf("const customPromptContext: CustomPromptContext");
|
||||
const sqlFileLoad = body.indexOf("await loadReferencedSqlFiles");
|
||||
const aiContextLoad = body.indexOf("await buildAiContext");
|
||||
|
||||
assert.notEqual(snapshot, -1, "send() should snapshot the custom prompt context");
|
||||
assert.ok(ensureLoadedAwait < snapshot, "the snapshot must be taken after templates finish loading");
|
||||
assert.ok(snapshot < sqlFileLoad, "SQL file loading must not delay the custom prompt snapshot");
|
||||
assert.ok(snapshot < aiContextLoad, "AI context loading must not delay the custom prompt snapshot");
|
||||
assert.match(body.slice(snapshot, sqlFileLoad), /activeTemplates:\s*runPluginContext \? \[\] : \[\.\.\.activeTemplates\.value\]/);
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
// AiAssistant.vue has no mounted-DOM harness in this repo (no @vue/test-utils),
|
||||
// so this pins the /skill contract on the compiled SFC source, the same way
|
||||
// aiAssistantComposerLayout.test.ts pins the composer layout.
|
||||
const source = readFileSync(new URL("../../apps/desktop/src/components/editor/AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
function section(startMarker: string, endMarker: string): string {
|
||||
const start = source.indexOf(startMarker);
|
||||
assert.notEqual(start, -1, `missing marker: ${startMarker}`);
|
||||
const end = source.indexOf(endMarker, start + startMarker.length);
|
||||
assert.notEqual(end, -1, `missing end marker: ${endMarker}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("/skill rides its own command source instead of the action lists", () => {
|
||||
const commands = section("const filteredCommands = computed", "const AI_SQL_FILE_MENTION_CANDIDATE_LIMIT");
|
||||
assert.ok(commands.includes('{ type: "skills" }'), "filteredCommands must merge a dedicated skills entry");
|
||||
assert.ok(commands.includes('type: "action"'), "action entries must stay in the same command list");
|
||||
|
||||
// The built-in action arrays must not gain a skill entry: they also feed the
|
||||
// mode+action picker, which would then offer /skill as a selectable action.
|
||||
for (const marker of ["/** Ask-mode actions: SQL-producing, never auto-run. */", "/** Agent-mode actions: task-oriented, drive tool use and real results. */"]) {
|
||||
const list = section(marker, "];");
|
||||
assert.ok(!/skill/i.test(list.replace(/AiActionButton/g, "")), `action list must not contain a skill entry: ${marker}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting /skill never changes the built-in action", () => {
|
||||
const body = section("function selectCommand(command: AiSlashEntry)", "\nfunction insertMention");
|
||||
const skillsBranch = body.indexOf('command.type === "skills"');
|
||||
const actionAssign = body.indexOf("activeAction.value = command.button.action");
|
||||
assert.notEqual(skillsBranch, -1, "selectCommand must branch on the skills entry");
|
||||
assert.notEqual(actionAssign, -1, "selectCommand must still assign the chosen action");
|
||||
assert.ok(skillsBranch < actionAssign, "the skills branch must be evaluated before any action assignment");
|
||||
|
||||
const branch = body.slice(skillsBranch, actionAssign);
|
||||
assert.ok(branch.includes("showSkillSelector.value = true"), "the skills branch must open the selector");
|
||||
assert.ok(branch.includes("return"), "the skills branch must return before the action assignment");
|
||||
assert.ok(!branch.includes("activeAction"), "the skills branch must not touch activeAction");
|
||||
});
|
||||
|
||||
test("the skills selector only ever lists metadata", () => {
|
||||
const selector = section('<Popover v-model:open="showSkillSelector">', 'v-if="mentionOpen"');
|
||||
assert.ok(selector.length > 0);
|
||||
assert.ok(selector.includes("userSkillStore.groupedSkills"), "the selector renders the metadata catalog");
|
||||
assert.ok(!selector.includes("readUserSkills"), "the selector must never read skill bodies");
|
||||
assert.ok(selector.includes("refreshSkills"), "the selector exposes Refresh");
|
||||
});
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { compileTemplate, parse } from "vue/compiler-sfc";
|
||||
|
||||
const aiAssistantPath = "apps/desktop/src/components/editor/AiAssistant.vue";
|
||||
const source = readFileSync(aiAssistantPath, "utf8");
|
||||
|
||||
function userMessageTemplate(): string {
|
||||
const start = source.indexOf(`<div v-if="msg.role === 'user'"`);
|
||||
const end = source.indexOf(`<div v-else-if="msg.content || msg.reasoning || msg.isThinking"`, start);
|
||||
|
||||
assert.notEqual(start, -1, "user message template should exist");
|
||||
assert.notEqual(end, -1, "assistant message template should follow user messages");
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function assistantMessageTemplate(): string {
|
||||
const start = source.indexOf(`<div v-else-if="msg.content || msg.reasoning || msg.isThinking"`);
|
||||
const end = source.indexOf(`</template>`, start);
|
||||
|
||||
assert.notEqual(start, -1, "assistant message template should exist");
|
||||
assert.notEqual(end, -1, "assistant message template should end before the message loop");
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("AI assistant template compiles", () => {
|
||||
const { descriptor, errors } = parse(source, { filename: aiAssistantPath });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(descriptor.template);
|
||||
|
||||
const result = compileTemplate({ id: aiAssistantPath, filename: aiAssistantPath, source: descriptor.template.content });
|
||||
assert.deepEqual(result.errors, []);
|
||||
});
|
||||
|
||||
test("user message edit action does not change short or wrapped message layout", () => {
|
||||
const template = userMessageTemplate();
|
||||
|
||||
assert.match(template, /class="relative min-w-0 max-w-\[85%\]"/);
|
||||
assert.match(template, /class="min-w-0"/);
|
||||
assert.match(template, /absolute right-full top-1 mr-1 flex h-5 w-5/);
|
||||
assert.match(template, /class="whitespace-pre-wrap"/);
|
||||
assert.doesNotMatch(template, /\bhidden\b[^\n>]*group-hover:flex/);
|
||||
});
|
||||
|
||||
test("user message edit action remains available by pointer and keyboard", () => {
|
||||
const template = userMessageTemplate();
|
||||
|
||||
assert.match(template, /group-hover:pointer-events-auto group-hover:opacity-100/);
|
||||
assert.match(template, /focus:pointer-events-auto focus:opacity-100/);
|
||||
assert.match(template, /:title="t\('ai\.editMessage'\)"[\s\S]*?@click="startEditMessage\(i\)"/);
|
||||
assert.match(template, /v-if="!isGenerating"/);
|
||||
});
|
||||
|
||||
test("assistant messages keep metadata aligned with the bubble and wrap long text", () => {
|
||||
const template = assistantMessageTemplate();
|
||||
|
||||
assert.match(template, /class="flex w-full max-w-\[95%\] min-w-0 flex-col"/);
|
||||
assert.match(template, /class="w-full[^"\n]*\[overflow-wrap:anywhere\]"/);
|
||||
});
|
||||
|
||||
test("AI request failures use localized backend diagnostics", () => {
|
||||
// The lookup is guarded (matching the `finally` block right below it) so that if
|
||||
// clearMessages()/selectConversation() already replaced the transcript while this
|
||||
// request was still in flight, writing the failure text doesn't throw and silently
|
||||
// swallow the real error. See issue #5941. (`runMessages` is the send pipeline's
|
||||
// array — `messages.value` for a visible conversation, the run's own array for a
|
||||
// detached/background run.) The failure flag rides along with the prefix text
|
||||
// (#6467 PR3): the export derives its failure marker from this state.
|
||||
assert.match(source, /const msg = runMessages\[assistantIdx\];\s*\n\s*if \(msg\) \{/);
|
||||
assert.match(source, /msg\.content = `\$\{t\("ai\.requestFailed"\)\}\\n\\n\$\{translateBackendError\(t, message\)\}`;/);
|
||||
assert.match(source, /msg\.failed = true;/);
|
||||
});
|
||||
|
||||
test("AI analysis export keeps the connection that produced each assistant response", () => {
|
||||
assert.match(source, /sourceConnectionName\?: string/);
|
||||
assert.match(source, /const runSourceName = runPluginContext\?\.pluginName \?\? connection\?\.name \?\? ""/);
|
||||
// The assistant placeholder must record the connection that produced it. The
|
||||
// literal also carries per-run fields (the frozen `sourceBinding` target,
|
||||
// #9902), so pin the pair instead of the closing brace.
|
||||
assert.match(source, /runMessages\.push\(\{ role: "assistant", content: "", sourceConnectionName: runSourceName,/);
|
||||
assert.match(source, /sourceBinding: runBinding \}\);/);
|
||||
// The fallback for a message that predates `sourceConnectionName` is the
|
||||
// conversation's own bound connection, not whatever tab is visible (#9902).
|
||||
assert.match(source, /connectionName: msg\.sourceConnectionName \?\? boundConnection\.value\?\.name/);
|
||||
assert.match(source, /sourceConnectionName: m\.role === "assistant" \? conv\.connectionName : undefined/);
|
||||
});
|
||||
-26
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -68,13 +67,6 @@ test("binary cell download menu closes when hover moves to another cell", () =>
|
||||
assert.equal(retainBinaryCellDownloadMenuForHover(openCell, { rowIndex: 2, col: 4 }), openCell);
|
||||
});
|
||||
|
||||
test("transpose cell hover also clears a different binary download menu", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const handler = source.match(/function onTransposeCellMouseenter\([^]*?\n\}/)?.[0] ?? "";
|
||||
|
||||
assert.match(handler, /retainBinaryCellDownloadMenuForHover\(quickDownloadMenuCell\.value, \{ rowIndex, col: actualColIdx \}\)/);
|
||||
});
|
||||
|
||||
test("canDownloadBinaryCellValue allows displayed binary hex strings", () => {
|
||||
assert.equal(canDownloadBinaryCellValue("0x89504e47", "BLOB"), true);
|
||||
assert.equal(canDownloadBinaryCellValue("0x89504e47"), true);
|
||||
@@ -284,13 +276,6 @@ test("binaryCellDownloadPayload builds raw and decoded payloads", () => {
|
||||
assert.deepEqual(Array.from(emptyBinary.data as Uint8Array), []);
|
||||
});
|
||||
|
||||
test("DataGrid binary preview prefers ResultSet column types when table metadata is unavailable", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const formatter = source.match(/function formatCell\([^]*?\n\}/)?.[0] ?? "";
|
||||
|
||||
assert.match(formatter, /binaryCellDisplayText\(value, columnIndex === undefined \? undefined : allColumnTypes\.value\[columnIndex\], originalBytes, resolvedDatabaseType\.value\)/);
|
||||
});
|
||||
|
||||
test("binaryCellDownloadPayload decodes GBK text bytes", () => {
|
||||
const payload = binaryCellDownloadPayload("0xd6d0cec4", "gbk");
|
||||
assert.equal(payload.data, "中文");
|
||||
@@ -319,14 +304,3 @@ test("formatBinaryCellByteSize formats human-readable sizes for the import toast
|
||||
// bytes >= 10 MB 时按整数 MB 显示(对齐 binaryCellDisplayText 既有格式)。
|
||||
assert.equal(formatBinaryCellByteSize(20 * 1024 * 1024), "20 MB");
|
||||
});
|
||||
|
||||
test("DataGrid import handler surfaces a dedicated too-large toast instead of the generic failure", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const handler = source.match(/async function importDetailBinaryValue\([^]*?\n\}/)?.[0] ?? "";
|
||||
|
||||
// 闸门错误必须走专门的文案,而非通用 binaryImportFailed。
|
||||
assert.match(handler, /e instanceof BinaryCellImportTooLargeError/);
|
||||
assert.match(handler, /grid\.binaryImportTooLarge/);
|
||||
assert.match(handler, /formatBinaryCellByteSize\(e\.bytes\)/);
|
||||
assert.match(handler, /formatBinaryCellByteSize\(e\.limit\)/);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { canvasDataGridActionReservedWidth, fitCanvasText, resolveCanvasCellTextLayout, resolveCanvasDataGridRowFill } from "../../apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts";
|
||||
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, resolveDataGridPaintTheme } from "../../apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts";
|
||||
@@ -43,14 +42,6 @@ test("canvas text layout reserves hover actions only for right-aligned cells", (
|
||||
assert.equal(canvasDataGridActionReservedWidth(true), 50);
|
||||
});
|
||||
|
||||
test("DataGrid forwards hover action reservation only for right-aligned canvas cells", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
|
||||
assert.match(source, /columnAligns\.value\[cell\.visibleColIdx\] !== "right"/);
|
||||
assert.match(source, /reservedWidth: canvasDataGridActionReservedWidth\(cell\.canQuickDownload, !!cell\.foreignKey, cellDetailButtonEnabled\.value, !!cell\.externalUrl\)/);
|
||||
assert.match(source, /rightAlignedActionCell: canvasRightAlignedActionCell\.value/);
|
||||
});
|
||||
|
||||
test("canvas row fill keeps frozen and scrolling regions on the same selection surface", () => {
|
||||
const theme = { cellActive: "active-blue", cellSelected: "selected-blue" };
|
||||
|
||||
|
||||
-13
@@ -1,5 +1,4 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import * as langSql from "@codemirror/lang-sql";
|
||||
import { createDbxCodeMirrorSqlDialect } from "../../apps/desktop/src/lib/editor/codemirrorSqlDialect.ts";
|
||||
@@ -141,15 +140,3 @@ test("keeps MySQL-compatible double-dash whitespace rules", () => {
|
||||
assert.equal(countParsedNodes(dialect, "-- SELECT 1", "LineComment", "-- SELECT 1"), 1, databaseType);
|
||||
}
|
||||
});
|
||||
|
||||
test("propagates database type to every DDL viewer entrypoint", () => {
|
||||
const ddlViewDialog = readFileSync("apps/desktop/src/components/objects/DdlViewDialog.vue", "utf8");
|
||||
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
|
||||
const app = readFileSync("apps/desktop/src/App.vue", "utf8");
|
||||
|
||||
assert.match(ddlViewDialog, /createDbxCodeMirrorSqlDialect\(langSql, props\.dialect, props\.databaseType\)/);
|
||||
assert.match(connectionTree, /<SidebarDdlViewDialog/);
|
||||
assert.match(connectionTree, /:database-type="sidebarDdlDatabaseType"/);
|
||||
assert.match(connectionTree, /v-model:open="sidebarDdlOpen"/);
|
||||
assert.match(app, /<QueryEditorDdlViewDialog[^>]*:database-type="queryEditorDdlDatabaseType"[^>]*\/>/);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, normalizeBooleanCellValue, parseBooleanCellEditorValue } from "../../apps/desktop/src/lib/dataGrid/dataGridBooleanColumn.ts";
|
||||
import { resolveDataGridColumnNullability, resolveDataGridColumnsByResultIndex } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnMetadata.ts";
|
||||
@@ -108,39 +107,3 @@ test("shows nullability only for query results with resolved column metadata", (
|
||||
assert.equal(resolveDataGridColumnNullability("table-data", nullable), undefined);
|
||||
assert.equal(resolveDataGridColumnNullability(undefined, nullable), undefined);
|
||||
});
|
||||
|
||||
test("keeps the enum editor as the default boolean edit path and gates checkbox interaction behind the checkbox display mode", () => {
|
||||
const gridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const rendererSource = readFileSync("apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts", "utf8");
|
||||
|
||||
assert.match(gridSource, /v-else-if="isBooleanGridCell\([^\n]+"[\s\S]*?v-model="booleanEditorModelValue"[\s\S]*?:values="BOOLEAN_CELL_EDITOR_VALUES"/);
|
||||
assert.match(gridSource, /@commit="commitBooleanGridEdit"/);
|
||||
// The editor-side cycle helper the old checkbox implementation depended on stays removed.
|
||||
assert.doesNotMatch(gridSource, /cycleBooleanCellValue/);
|
||||
// Checkbox rendering and click cycling only exist behind the checkbox display mode.
|
||||
assert.match(gridSource, /booleanCellsUseCheckbox\.value/);
|
||||
assert.match(rendererSource, /booleanDisplayMode === "checkbox"/);
|
||||
});
|
||||
|
||||
test("DOM checkbox mode renders a clickable placeholder for null boolean cells so they can be cycled like canvas", () => {
|
||||
const gridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
// Canvas surfaces null booleans via booleanNullTextHitFromCanvasEvent (click the NULL text to cycle).
|
||||
// The DOM path must offer the same affordance: a null boolean cell in checkbox mode renders its NULL
|
||||
// text with a click handler that triggers cycleBooleanGridCell, instead of falling through to the
|
||||
// static v-else text (which cannot be cycled and is short-circuited by onDomCellDblClick).
|
||||
const domCellBranch = gridSource.match(/<template v-else-if="booleanCellsUseCheckbox && isBooleanGridCell\([^\n]+=== null[\s\S]*?cycleBooleanGridCell/);
|
||||
assert.ok(domCellBranch, "DOM checkbox mode must render a clickable cycle placeholder for null boolean cells");
|
||||
assert.match(domCellBranch![0], /@click\.stop="cycleBooleanGridCell/);
|
||||
assert.match(domCellBranch![0], /text-muted-foreground/);
|
||||
});
|
||||
|
||||
test("uses the indexed metadata lookup in grid hot paths", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const start = source.indexOf("function tableColumnForGridColumn");
|
||||
const end = source.indexOf("function resultColumnInfoForGridColumn", start);
|
||||
const lookup = source.slice(start, end);
|
||||
|
||||
assert.ok(start >= 0 && end > start);
|
||||
assert.match(lookup, /tableColumnsByResultIndex\.value\[columnIndex\]/);
|
||||
assert.doesNotMatch(lookup, /\.find\(/);
|
||||
});
|
||||
-72
@@ -1,20 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { compileScript, parse } from "vue/compiler-sfc";
|
||||
import { createDataGridCellContextMenuItems } from "../../apps/desktop/src/lib/dataGrid/dataGridContextMenu";
|
||||
|
||||
const dataGridPath = "apps/desktop/src/components/grid/DataGrid.vue";
|
||||
const dataGridSource = readFileSync(dataGridPath, "utf8");
|
||||
|
||||
test("DataGrid context menu script compiles", () => {
|
||||
const { descriptor, errors } = parse(dataGridSource, { filename: dataGridPath });
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(descriptor.scriptSetup);
|
||||
compileScript(descriptor, { id: "data-grid-context-menu-test" });
|
||||
});
|
||||
|
||||
test("set NULL applies a real null value only to editable selections", () => {
|
||||
const handler = dataGridSource.match(/function setSelectionNull\(\) \{[^]*?\n\}/)?.[0] ?? "";
|
||||
|
||||
@@ -23,69 +14,6 @@ test("set NULL applies a real null value only to editable selections", () => {
|
||||
assert.doesNotMatch(handler, /fillSelectionWithValue\(["'](?:NULL)?["']\)/);
|
||||
});
|
||||
|
||||
test("generated selection values restore grid focus for keyboard shortcuts", () => {
|
||||
const applyHandler = dataGridSource.match(/function applyGeneratedSelectionValue\([^]*?\n\}/)?.[0] ?? "";
|
||||
|
||||
assert.match(applyHandler, /if \(applied\)[^]*nextTick\(\(\) => window\.requestAnimationFrame\(\(\) => gridRef\.value\?\.focus\(\{ preventScroll: true \}\)\)\)/);
|
||||
});
|
||||
|
||||
test("column-header context menus defer copy statement generation", () => {
|
||||
const handler = dataGridSource.match(/function onHeaderContext\([^]*?\n\}/)?.[0] ?? "";
|
||||
|
||||
assert.doesNotMatch(handler, /prefetchCopyStatements/);
|
||||
});
|
||||
|
||||
test("context-menu invalidation clears cell and header targets together", () => {
|
||||
const handler = dataGridSource.match(/function invalidateContextMenuTarget\(\) \{[^]*?\n\}/)?.[0] ?? "";
|
||||
|
||||
assert.match(handler, /contextSelectionIsSynthetic\.value = false;/);
|
||||
assert.match(handler, /contextCell\.value = null;/);
|
||||
assert.match(handler, /contextHeaderColumn\.value = null;/);
|
||||
assert.match(handler, /contextHeaderColumnIndex\.value = null;/);
|
||||
assert.match(handler, /contextHeaderVisibleColIdx\.value = null;/);
|
||||
});
|
||||
|
||||
test("header context menu wires batch hide and show-all to the column layout pipeline", () => {
|
||||
assert.match(dataGridSource, /hideColumn: hideContextColumn,/);
|
||||
assert.match(dataGridSource, /hideSelectedColumns,/);
|
||||
assert.match(dataGridSource, /showAllColumnsMenu: showAllColumns,/);
|
||||
assert.match(dataGridSource, /hiddenColumnCount: hiddenColumnCount\.value,/);
|
||||
|
||||
const hideWrapper = dataGridSource.match(/function hideColumns\(columnIndexes: number\[\]\) \{[^]*?\n\}/)?.[0] ?? "";
|
||||
|
||||
assert.match(hideWrapper, /applyColumnOrderChange\(\(\) => hideColumnsInLayout\(columnIndexes\)\)/);
|
||||
assert.match(hideWrapper, /clearCellSelection\(\);/);
|
||||
assert.match(hideWrapper, /clampGridHorizontalScroll\(\)/);
|
||||
|
||||
// 显示全部列也走 applyColumnOrderChange,隐藏期间产生的可见索引偏移会被重新对齐。
|
||||
const showAllWrapper = dataGridSource.match(/function showAllColumns\(\) \{[^]*?\n\}/)?.[0] ?? "";
|
||||
|
||||
assert.match(showAllWrapper, /applyColumnOrderChange\(showAllColumnsInLayout\)/);
|
||||
});
|
||||
|
||||
test("select-all context menus invalidate a stale specialized target", () => {
|
||||
const header = dataGridSource.match(/<div\s+class="data-grid-header-cell shrink-0 px-2 py-1\.5[^]*?@click="selectAllCells"[^]*?>/u)?.[0] ?? "";
|
||||
|
||||
assert.match(header, /@contextmenu="invalidateContextMenuTarget"/);
|
||||
});
|
||||
|
||||
test("right-clicking outside the old cell or column selection resets it before selecting the target", () => {
|
||||
const handler = dataGridSource.match(/function onCellContext\([^]*?\n\}/)?.[0] ?? "";
|
||||
const clear = handler.indexOf("clearCellSelection();");
|
||||
const select = handler.indexOf("selectSingleCell(rowIndex, visibleColIdx);");
|
||||
|
||||
assert.ok(clear >= 0);
|
||||
assert.ok(clear < select);
|
||||
});
|
||||
|
||||
test("menu-close invalidation is deferred and guarded from a newer open", () => {
|
||||
const handler = dataGridSource.match(/function onGridContextMenuClose\(\) \{[^]*?\n\}/)?.[0] ?? "";
|
||||
|
||||
assert.match(handler, /queueMicrotask\(\(\) => \{/);
|
||||
assert.match(handler, /if \(lifecycle !== contextMenuLifecycle\) return;/);
|
||||
assert.match(handler, /invalidateContextMenuTarget\(\);/);
|
||||
});
|
||||
|
||||
test("editable cell selections expose generation after bulk edit", () => {
|
||||
const icon = {};
|
||||
const action = () => {};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { resolveDataGridColumnsByResultIndex } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnMetadata.ts";
|
||||
import { resolveDataGridNewRowCellPlaceholder } from "../../apps/desktop/src/lib/dataGrid/dataGridDefaultPlaceholder.ts";
|
||||
@@ -50,15 +49,3 @@ test("existing nulls, explicit values, and unmapped new columns do not show sche
|
||||
assert.equal(resolveDataGridNewRowCellPlaceholder({ row: { data: [null], isNew: true }, columnIndex: 0, column: undefined, draftFallback: "New" }), null);
|
||||
assert.equal(resolveDataGridNewRowCellPlaceholder({ row: { data: [null], isNew: false, isDraft: true }, columnIndex: 0, column: undefined, draftFallback: "New" }), "New");
|
||||
});
|
||||
|
||||
test("DOM and canvas paths consume the same per-cell placeholder resolver", () => {
|
||||
const dataGridSource = readFileSync(new URL("../../apps/desktop/src/components/grid/DataGrid.vue", import.meta.url), "utf8");
|
||||
const canvasSource = readFileSync(new URL("../../apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(dataGridSource, /function newRowCellPlaceholder\(item: RowItem \| undefined, columnIndex: number\)/);
|
||||
assert.match(dataGridSource, /newRowCellPlaceholder,\n\s+isRowActive/);
|
||||
assert.match(dataGridSource, /newRowCellPlaceholder\(displayItems\[cell\.recordIndex\], cell\.valueIndex\)/);
|
||||
assert.match(dataGridSource, /newRowCellPlaceholder\(item, col\.actualColIdx\)/);
|
||||
assert.match(canvasSource, /newRowCellPlaceholder\?: \(row: CanvasDataGridRow, columnIndex: number\) => string \| null/);
|
||||
assert.match(canvasSource, /newRowCellPlaceholder\?\.\(item, actualColIdx\)/);
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
|
||||
test("unused header width keeps the result background", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const { descriptor } = parse(source, { filename: "DataGrid.vue" });
|
||||
const style = descriptor.styles.map((block) => block.content).join("\n");
|
||||
|
||||
assert.match(style, /\.data-grid-header-shell\s*\{[^}]*background-color:\s*var\(--background\)/s);
|
||||
assert.match(style, /\.data-grid-header-cell\s*\{[^}]*background-color:\s*rgb\(239,\s*239,\s*239\)/s);
|
||||
assert.doesNotMatch(style, /\.data-grid-header-shell\s*,\s*\.data-grid-header-cell/);
|
||||
assert.doesNotMatch(style, /data-grid--dark\s+\.data-grid-header-shell/);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { dataGridScrollPosition, isDataGridNearScrollBottom, isDataGridPrefixAppend, shouldCheckInfiniteScrollAfterScroll } from "../../apps/desktop/src/lib/dataGrid/dataGridInfiniteScroll.ts";
|
||||
|
||||
@@ -39,12 +38,3 @@ test("append marker does not preserve state when an existing row was replaced",
|
||||
assert.equal(isDataGridPrefixAppend(previous, { rows: [first, [...second], [3, "Grace"]], appended_from_row_count: 2 }), false);
|
||||
assert.equal(isDataGridPrefixAppend(previous, { rows: [first, second, [3, "Grace"]] }), false);
|
||||
});
|
||||
|
||||
test("infinite scroll requests only the next bounded segment", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
assert.match(source, /function infiniteScrollNextPage\(\) \{[\s\S]*?if \(!canFetchNextInfiniteScrollSegment\.value\) \{[\s\S]*?infiniteScrollAllLoaded = true;[\s\S]*?return;[\s\S]*?\}/);
|
||||
assert.match(source, /const nextOffset = props\.result\.rows\.length/);
|
||||
assert.match(source, /Math\.min\(pageSize\.value, remainingRows\)/);
|
||||
assert.doesNotMatch(source, /emit\("paginate", 0, cumulativeLimit/);
|
||||
assert.match(source, /props\.result\.appended_from_row_count !== requestedOffset/);
|
||||
});
|
||||
-82
@@ -1,5 +1,4 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { canFetchNextDataGridSegment, canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "../../apps/desktop/src/lib/dataGrid/dataGridPagination.ts";
|
||||
|
||||
@@ -235,84 +234,3 @@ test("auto-redirect: total is undefined — guard prevents redirect attempt", ()
|
||||
const total = undefined;
|
||||
assert.equal(!total || (total as any) <= 0, true, "guard should prevent redirect when total is unknown");
|
||||
});
|
||||
|
||||
test("explicit loads, last-page COUNTs, and page jumps block the grid surface; background counts do not", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
assert.match(source, /const totalRowCountBusy = computed\(\(\) => props\.totalRowCountLoading === true \|\| manualTotalRowCountLoading\.value\)/);
|
||||
assert.match(source, /const gridSurfaceBusy = computed\(\(\) => isRefreshingData\.value \|\| props\.loading === true \|\| manualTotalRowCountLoading\.value \|\| pageJumpBusy\.value\)/);
|
||||
assert.match(source, /const pageJumpBusy = computed\(\(\) => !!props\.pageJumpProgress && props\.pageJumpProgress\.totalRequests > 1\)/);
|
||||
// totalRowCountBusy must stay out of gridSurfaceBusy so background counts keep the grid interactive (#5967)
|
||||
const busyLine = source.match(/const gridSurfaceBusy = computed\([^\n]*/)?.[0] ?? "";
|
||||
assert.ok(!busyLine.includes("totalRowCountBusy"), "background counts must not block the grid surface");
|
||||
assert.match(source, /const gridPaginationBusy = computed\(\(\) => gridSurfaceBusy\.value \|\| totalRowCountBusy\.value\)/);
|
||||
assert.match(source, /v-if="gridSurfaceBusy"/);
|
||||
assert.match(source, /:loading="gridPaginationBusy"/);
|
||||
assert.match(source, /async function beginManualTotalRowCount/);
|
||||
assert.match(source, /await nextTick\(\);/);
|
||||
const lastPageFn = source.match(/async function lastPage\(\) \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
assert.match(lastPageFn, /beginManualTotalRowCount\(\)/);
|
||||
assert.match(lastPageFn, /buildCurrentCountTarget\(\)/);
|
||||
assert.ok(lastPageFn.indexOf("beginManualTotalRowCount") < lastPageFn.indexOf("buildCurrentCountTarget"), "busy UI must start before COUNT SQL is built");
|
||||
});
|
||||
|
||||
test("last page always re-counts when a count path is available", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const lastPageFn = source.match(/async function lastPage\(\) \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
const knownTotalIdx = lastPageFn.indexOf("hasKnownPaginationTotalRowCount");
|
||||
const countCallbackIdx = lastPageFn.indexOf("props.countTotalRows");
|
||||
const countSqlIdx = lastPageFn.indexOf("buildCurrentCountTarget");
|
||||
assert.ok(countCallbackIdx >= 0 && countSqlIdx >= 0, "last page must keep count paths");
|
||||
assert.ok(knownTotalIdx < 0 || knownTotalIdx > countSqlIdx, "known totals are only a fallback after re-COUNT");
|
||||
});
|
||||
|
||||
test("jumping to last page does not rewrite indexes before the new page loads", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const jumpFn = source.match(/function jumpToCountedLastPage\(total: number\) \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
assert.match(jumpFn, /resolveDataGridPaginationTotal/);
|
||||
assert.match(jumpFn, /maxRows: paginationMaxRows\.value/);
|
||||
// Last-page jumps delegate to requestServerPageJump (ES deep-jump confirm) which still emits "paginate" without bumping currentPage
|
||||
assert.match(jumpFn, /requestServerPageJump\(lastPageNum\)/);
|
||||
const executeJumpFn = source.match(/function executeServerPageJump[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
assert.match(executeJumpFn, /emit\("paginate"/);
|
||||
assert.doesNotMatch(jumpFn, /currentPage\.value\s*=/);
|
||||
assert.match(source, /function rowNumberPageOffset/);
|
||||
});
|
||||
|
||||
test("query result caps do not limit table-data pagination", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
assert.match(source, /const paginationMaxRows = computed\(\(\) => \(isResultsContext\.value \? queryResultMaxRows\.value : undefined\)\)/);
|
||||
assert.equal(source.match(/maxRows: paginationMaxRows\.value/g)?.length, 2);
|
||||
});
|
||||
|
||||
test("row number gutter width tracks the largest visible row index", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
assert.match(source, /dataGridRowNumberColumnWidth/);
|
||||
assert.match(source, /resolveDataGridMaxRowNumber/);
|
||||
assert.match(source, /rowNumberWidth,/);
|
||||
});
|
||||
|
||||
test("rerun total-count visibility comes from the shared rule and triggers the manual count", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const rerunComputed = source.match(/const showRerunTotalCountAction = computed\(\(\) =>[\s\S]*?\n\);/)?.[0] ?? "";
|
||||
assert.match(rerunComputed, /showDataGridRerunTotalCountAction\(\{/);
|
||||
assert.match(rerunComputed, /canCalculateTotalRowCount: canCalculateTotalRowCount\.value/);
|
||||
assert.match(rerunComputed, /displayedTotalRowCount: displayedTotalRowCount\.value/);
|
||||
assert.match(rerunComputed, /totalRowCountIsExact: totalRowCountIsExact\.value/);
|
||||
const rerunButton = source.match(/<i18n-t v-if="showRerunTotalCountAction[\s\S]*?<template #button>[\s\S]*?<button[\s\S]*?<\/button>/)?.[0] ?? "";
|
||||
assert.match(rerunButton, /@click="calculateTotalRowCount"/);
|
||||
});
|
||||
|
||||
test("manual rerun counts through props.countSql and resets when the query context changes", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
assert.match(source, /if \(props\.countSql\) return \{ sql: props\.countSql, schema: props\.schema \};/);
|
||||
assert.match(source, /const serverKnownTotalRowCount = computed\(\(\) => \(typeof manualTotalRowCount\.value === "number" \? manualTotalRowCount\.value : props\.totalRowCount\)\)/);
|
||||
assert.match(source, /watch\(\s*\(\) => \[props\.countSql \?\? ""/);
|
||||
});
|
||||
|
||||
test("executed plans refresh the count SQL shared with the background count", () => {
|
||||
const source = readFileSync("apps/desktop/src/stores/queryStore.ts", "utf8");
|
||||
assert.ok((source.match(/current\.resultCountSql = countSql;/g)?.length ?? 0) >= 2, "every executed-plan path must refresh the tab count SQL");
|
||||
assert.match(source, /countSql = sqlServerUseScript && plan\.countSql \? replaceSqlServerLeadingUseQuery\(queryBaseSql, sqlServerUseScript, plan\.countSql\) : plan\.countSql;/);
|
||||
const backgroundCount = source.match(/countQueryTotalRowsInBackground\(\{[\s\S]*?\}\);/)?.[0] ?? "";
|
||||
assert.match(backgroundCount, /\n\s+countSql,/);
|
||||
});
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { compileScript, parse } from "vue/compiler-sfc";
|
||||
|
||||
function compiledDataGridScript(): string {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const { descriptor } = parse(source, { filename: "DataGrid.vue" });
|
||||
return compileScript(descriptor, { id: "data-grid-props-test" }).content;
|
||||
}
|
||||
|
||||
test("DataGrid template components have local or documented global bindings", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const { descriptor } = parse(source, { filename: "DataGrid.vue" });
|
||||
const bindings = compileScript(descriptor, { id: "data-grid-component-bindings" }).bindings;
|
||||
assert.ok(descriptor.template);
|
||||
|
||||
const globalComponents = new Set(["RecycleScroller"]);
|
||||
const componentTags = new Set(Array.from(descriptor.template.content.matchAll(/<([A-Z][A-Za-z0-9]*)\b/g), (match) => match[1]));
|
||||
for (const tag of componentTags) {
|
||||
assert.ok(bindings[tag] || globalComponents.has(tag), `DataGrid template component ${tag} must be imported or explicitly registered globally`);
|
||||
}
|
||||
});
|
||||
|
||||
test("row action limit props default to undefined when omitted", () => {
|
||||
const compiled = compiledDataGridScript();
|
||||
|
||||
// Vue Boolean props normally cast an absent prop to false. DataGrid callers
|
||||
// omit these props to mean "not restricted", so the runtime default matters.
|
||||
assert.match(compiled, /allowInsertRows:\s*\{\s*type:\s*Boolean,\s*required:\s*false,\s*default:\s*undefined\s*\}/);
|
||||
assert.match(compiled, /allowDeleteRows:\s*\{\s*type:\s*Boolean,\s*required:\s*false,\s*default:\s*undefined\s*\}/);
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "vitest";
|
||||
|
||||
/**
|
||||
* The Ctrl+F overlay was the only grid search/filter surface without tab-keyed
|
||||
* persistence (structured filters and column widths already had it), so switching
|
||||
* data tabs dropped the query along with the scroll position (#8524).
|
||||
*/
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(path.resolve(relativePath), "utf8");
|
||||
}
|
||||
|
||||
test("DataGrid keys search persistence the same way as its pending-edit snapshot", () => {
|
||||
const source = readSource("apps/desktop/src/components/grid/DataGrid.vue");
|
||||
assert.match(source, /persistenceKey: \(\) => props\.pendingStateKey \?\? props\.cacheKey,/);
|
||||
assert.match(source, /persistenceScopeKey: \(\) => createDataGridSearchScopeKey\(props\.result\.columns\),/);
|
||||
});
|
||||
|
||||
test("search state is restored from onMounted, not the setup body", () => {
|
||||
const source = readSource("apps/desktop/src/components/grid/DataGrid.vue");
|
||||
// watch(matchKeys) evaluates its source eagerly, and a non-empty restored query
|
||||
// would push matchState past its empty-query early return into `displayItems`,
|
||||
// which is declared thousands of lines later in this same setup.
|
||||
assert.match(source, /onMounted\(\(\) => dataGridSearch\.restorePersistedState\(\)\);/);
|
||||
const restoreIndex = source.indexOf("onMounted(() => dataGridSearch.restorePersistedState());");
|
||||
const displayItemsIndex = source.indexOf("const displayItems");
|
||||
assert.notEqual(restoreIndex, -1);
|
||||
assert.notEqual(displayItemsIndex, -1);
|
||||
assert.ok(restoreIndex < displayItemsIndex, "the restore hook is registered before displayItems is declared, so it must run in onMounted");
|
||||
});
|
||||
|
||||
test("restoring a query does not steal the viewport from the scroll restore", () => {
|
||||
const source = readSource("apps/desktop/src/composables/useDataGridSearch.ts");
|
||||
const start = source.indexOf("watch(matchKeys");
|
||||
assert.notEqual(start, -1);
|
||||
const body = source.slice(start, source.indexOf("\n });", start));
|
||||
assert.match(body, /const restored = restoreToken;\s*\n\s*restoreToken = null;/, "the token must be consumed exactly once");
|
||||
assert.match(body, /if \(restored\) \{[\s\S]*?Math\.min\(Math\.max\(restored\.matchIndex, 0\), value\.length - 1\)[\s\S]*?return;/, "a restored match index is clamped and must not auto-navigate");
|
||||
});
|
||||
|
||||
test("closed tabs release their persisted search state", () => {
|
||||
const source = readSource("apps/desktop/src/stores/queryStore.ts");
|
||||
const filterCalls = source.match(/clearDataGridStructuredFilterStatesForTab\(/g) ?? [];
|
||||
const searchCalls = source.match(/clearDataGridSearchStatesForTab\(/g) ?? [];
|
||||
assert.ok(filterCalls.length > 1, "expected the structured-filter cleanup call sites");
|
||||
// One import plus one call per cleanup site, mirroring the filter cache.
|
||||
assert.equal(searchCalls.length, filterCalls.length, "every structured-filter cleanup site must also clear the search cache");
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "vitest";
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(path.resolve(relativePath), "utf8");
|
||||
}
|
||||
|
||||
function editorSource(): string {
|
||||
return readSource("apps/desktop/src/composables/useDataGridEditor.ts");
|
||||
}
|
||||
|
||||
test("the restore gate honours a tab-switch scroll snapshot", () => {
|
||||
assert.match(editorSource(), /pendingScrollRestore = snapshotHasEditState \|\| cached\.scrollFromTabSwitch === true \? cached\.scroll : undefined;/, "the scroll-only gate must accept snapshots carrying tab-switch provenance");
|
||||
});
|
||||
|
||||
test("the tab-switch listener only claims its own tab", () => {
|
||||
const source = editorSource();
|
||||
const start = source.indexOf("function onBeforeTabSwitch");
|
||||
assert.notEqual(start, -1, "expected onBeforeTabSwitch in useDataGridEditor.ts");
|
||||
const body = source.slice(start, source.indexOf("\n }", start));
|
||||
assert.match(body, /detail\?\.fromTabId/, "must read fromTabId off the event detail");
|
||||
assert.match(body, /cacheKeyBelongsToTab\(key, detail\.fromTabId\)/, "must validate fromTabId against this grid's cache key");
|
||||
assert.match(body, /scrollSnapshotFromTabSwitch = true/);
|
||||
});
|
||||
|
||||
test("the snapshot records the tab-switch provenance", () => {
|
||||
assert.match(editorSource(), /scrollFromTabSwitch: scroll \? scrollSnapshotFromTabSwitch : false,/);
|
||||
});
|
||||
|
||||
test("an explicit scroll-to-top intent drops the provenance", () => {
|
||||
const source = editorSource();
|
||||
assert.match(source, /function clearTabSwitchScrollProvenance\(\)/);
|
||||
const reset = source.slice(source.indexOf("function resetGridVerticalScroll"));
|
||||
assert.match(reset.slice(0, reset.indexOf("\n }")), /clearTabSwitchScrollProvenance\(\);/, "resetGridVerticalScroll must clear the provenance");
|
||||
// The rows-identity watcher already clears pendingScrollRestore; it must clear
|
||||
// the cached provenance too, or a reload would inherit the old offset.
|
||||
assert.match(source, /pendingScrollRestore = undefined;\s*\n\s*clearTabSwitchScrollProvenance\(\);/);
|
||||
});
|
||||
|
||||
test("data tab panes bound hot surface caching without adding nested caches", () => {
|
||||
const editorGroup = readSource("apps/desktop/src/components/layout/EditorGroup.vue");
|
||||
assert.match(editorGroup, /const HOT_TAB_SURFACE_CACHE_SIZE = 3;/);
|
||||
assert.match(editorGroup, /<KeepAlive\b[^>]*:max="HOT_TAB_SURFACE_CACHE_SIZE"/);
|
||||
assert.doesNotMatch(readSource("apps/desktop/src/components/layout/ContentArea.vue"), /<KeepAlive|<keep-alive/);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { useDataGridCanvasRuntime, type DataGridAnimationFrameDriver } from "../../apps/desktop/src/composables/useDataGridCanvasRuntime.ts";
|
||||
import { useDataGridScrollbars } from "../../apps/desktop/src/composables/useDataGridScrollbars.ts";
|
||||
@@ -159,20 +158,3 @@ test("restores the vertical grid position after a keyboard transpose round trip"
|
||||
|
||||
assert.equal(viewport.scrollTop, 2280, "restored position should stay within the remounted grid bounds");
|
||||
});
|
||||
|
||||
test("error result recovery participates in canvas observer remounts", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
assert.match(source, /watch\(\s*\[useCanvasGridRows, hasVisibleRows, isErrorResult\]/);
|
||||
});
|
||||
|
||||
test("column navigation scrolls visible transpose fields before looking for the normal grid", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const navigation = source.slice(source.indexOf("function scrollToColumnIndex("), source.indexOf("// --- Column resize composable ---"));
|
||||
assert.match(navigation, /showColumn\(columnIndex\)/);
|
||||
assert.match(navigation, /visibleColumnIndexes\.value\.indexOf\(columnIndex\)/);
|
||||
assert.match(navigation, /if \(isTransposeMode\.value\) \{\s*scrollTransposeFieldIntoView\(visibleColIdx\);\s*return;/);
|
||||
assert.ok(navigation.indexOf("scrollTransposeFieldIntoView") < navigation.indexOf('".data-grid-scroller"'));
|
||||
assert.match(source, /scrollToItem\?\.\(visibleFieldIndex\)/);
|
||||
assert.match(source, /scroller\.scrollTop = visibleFieldIndex \* transposeRowHeight\.value/);
|
||||
assert.match(source, /'ring-2 ring-inset ring-primary': highlightedColumnIndex === visibleColumnIndexes\[index\]/);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { compileTemplate, parse } from "vue/compiler-sfc";
|
||||
|
||||
const contentAreaPath = "apps/desktop/src/components/layout/ContentArea.vue";
|
||||
|
||||
function dataModeTemplate(): string {
|
||||
const source = readFileSync(contentAreaPath, "utf8");
|
||||
const { descriptor, errors } = parse(source, { filename: contentAreaPath });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(descriptor.template);
|
||||
const result = compileTemplate({ id: contentAreaPath, filename: contentAreaPath, source: descriptor.template.content });
|
||||
assert.deepEqual(result.errors, []);
|
||||
|
||||
const start = descriptor.template.content.indexOf("activeTab.mode === 'data'");
|
||||
const end = descriptor.template.content.indexOf("activeTab.mode === 'redis'", start);
|
||||
assert.ok(start >= 0 && end > start, "ContentArea should keep a bounded data-mode template");
|
||||
return descriptor.template.content.slice(start, end);
|
||||
}
|
||||
|
||||
test("data tab header shows the active connection before the table context", () => {
|
||||
const template = dataModeTemplate();
|
||||
const connectionIndex = template.indexOf("data-data-header-connection");
|
||||
const tableIndex = template.indexOf("activeDataTabTableMeta?.tableName");
|
||||
|
||||
assert.ok(connectionIndex >= 0 && connectionIndex < tableIndex);
|
||||
assert.match(template, /v-if="activeConnection\?\.name\?\.trim\(\)"/);
|
||||
assert.match(template, /:title="activeConnection\.name"/);
|
||||
assert.doesNotMatch(template, /data-data-header-connection class="[^"]*(?:max-w-4[89]|max-w-5[0-9])/);
|
||||
// min-w floor keeps scrollWidth reporting real overflow for the measured
|
||||
// condensation tiers; chips still flex and only truncate under pressure.
|
||||
assert.match(template, /data-data-header-connection class="[^"]*min-w-12[^"]*truncate/);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { beforeEach, test, vi } from "vitest";
|
||||
|
||||
const { beginDatabaseBackupSnapshot, exportDatabaseSql, rollbackManualTransaction } = vi.hoisted(() => ({
|
||||
@@ -55,14 +54,6 @@ test("database export waits for terminal progress before releasing its snapshot"
|
||||
assert.equal((await operation).status, "Done");
|
||||
});
|
||||
|
||||
test("single database export holds its snapshot until terminal progress", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/export/DatabaseExportDialog.vue", "utf8");
|
||||
const singleExport = source.slice(source.indexOf("async function startExport()"), source.indexOf("async function startAllDatabasesExport()"));
|
||||
|
||||
assert.match(singleExport, /return runDatabaseExportUntilTerminal\(request,/);
|
||||
assert.doesNotMatch(singleExport, /await api\.exportDatabaseSql\(request,/);
|
||||
});
|
||||
|
||||
test("database export snapshot is released after success", async () => {
|
||||
const operation = vi.fn(async (sessionId: string | undefined) => `done:${sessionId}`);
|
||||
|
||||
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { compileScript, compileTemplate, parse } from "vue/compiler-sfc";
|
||||
|
||||
const dialogPath = "apps/desktop/src/components/search/DatabaseSearchDialog.vue";
|
||||
const dialogSource = readFileSync(dialogPath, "utf8");
|
||||
|
||||
test("database search dialog compiles", () => {
|
||||
const { descriptor, errors } = parse(dialogSource, { filename: dialogPath });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(descriptor.scriptSetup);
|
||||
compileScript(descriptor, { id: dialogPath });
|
||||
assert.ok(descriptor.template);
|
||||
const template = compileTemplate({ id: dialogPath, filename: dialogPath, source: descriptor.template!.content });
|
||||
assert.deepEqual(template.errors, []);
|
||||
});
|
||||
|
||||
test("database search continues after each table batch without retaining a hard cap", () => {
|
||||
assert.doesNotMatch(dialogSource, /MAX_TABLES|tasks\.slice\(0/);
|
||||
assert.match(dialogSource, /progressTotal\.value = tableTasks\.value\.length/);
|
||||
assert.match(dialogSource, /await scanTableRange\(currentRun, false\)/);
|
||||
assert.match(dialogSource, /@click="continueSearch\(false\)"/);
|
||||
assert.match(dialogSource, /@click="continueSearch\(true\)"/);
|
||||
assert.match(dialogSource, /activeKeyword\.value/);
|
||||
assert.match(dialogSource, /activePerTableLimit\.value/);
|
||||
assert.match(dialogSource, /const tableResults: SearchResultItem\[\] = \[\]/);
|
||||
assert.match(dialogSource, /currentRun === runId && !cancelled\.value/);
|
||||
});
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "vitest";
|
||||
|
||||
function source(relativePath: string): string {
|
||||
return readFileSync(path.resolve(relativePath), "utf8");
|
||||
}
|
||||
|
||||
test("shares the configured download source with agent driver management", () => {
|
||||
const app = source("apps/desktop/src/App.vue");
|
||||
const driverStore = source("apps/desktop/src/components/config/DriverStoreDialog.vue");
|
||||
const backendApi = source("apps/desktop/src/lib/backend/api.ts");
|
||||
|
||||
assert.match(driverStore, /driverStoreTab === 'agent' && !isWeb/);
|
||||
assert.match(driverStore, /:model-value="settingsStore\.editorSettings\.updateDownloadSource"/);
|
||||
assert.match(driverStore, /settingsStore\.updateEditorSettings\(\{ updateDownloadSource: value \}\)/);
|
||||
assert.match(driverStore, /void forceRefresh\(\)\.catch\(\(\) => undefined\)/);
|
||||
|
||||
assert.match(backendApi, /backend\.listInstalledAgents\(useSettingsStore\(\)\.editorSettings\.updateDownloadSource\)/);
|
||||
assert.match(backendApi, /backend\.installAgent\(dbType, useSettingsStore\(\)\.editorSettings\.updateDownloadSource(?:, operationId)?\)/);
|
||||
assert.match(backendApi, /backend\.upgradeAllAgents\(useSettingsStore\(\)\.editorSettings\.updateDownloadSource(?:, operationId)?\)/);
|
||||
assert.match(backendApi, /backend\.reinstallJre\(jreKey, useSettingsStore\(\)\.editorSettings\.updateDownloadSource(?:, operationId)?\)/);
|
||||
assert.match(app, /const drivers = await api\.listInstalledAgents\(\)/);
|
||||
assert.doesNotMatch(app, /invoke<[^>]+>\("list_installed_agents"\)/);
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "vitest";
|
||||
|
||||
function source(relativePath: string): string {
|
||||
return readFileSync(path.resolve(relativePath), "utf8");
|
||||
}
|
||||
|
||||
test("Driver Manager exposes an install-status filter next to search", () => {
|
||||
const driverStore = source("apps/desktop/src/components/config/DriverStoreDialog.vue");
|
||||
|
||||
assert.match(driverStore, /data-driver-status-filter/);
|
||||
assert.match(driverStore, /driverStatusFilter = ref<DriverInstallStatusFilter>\("all"\)/);
|
||||
assert.match(driverStore, /partitionDriversByInstallStatus\(builtinDriverRows\.value, driverStatusFilter\.value\)/);
|
||||
assert.match(driverStore, /upgradeAllDriverTypes\(builtinDriverRows\.value, driverStatusFilter\.value\)/);
|
||||
assert.match(driverStore, /if \(!upgradeAllMatchesFullUpdateSet\(builtinDriverRows\.value, driverStatusFilter\.value\)\) return/);
|
||||
assert.match(driverStore, /upgradeAllMatchesFullUpdateSet\(builtinDriverRows\.value, driverStatusFilter\.value\)/);
|
||||
assert.match(driverStore, /driverStatusFilter\.value = "all"/);
|
||||
assert.match(driverStore, /shouldApplyDriverStoreFocus\(lastAppliedFocusKey\.value, key, focusChanged\)/);
|
||||
assert.match(driverStore, /driverStoreFocusRowIsRenderable\(focus, drivers\.value\.length, builtinDriverRows\.value\)/);
|
||||
const applyIdx = driverStore.indexOf("if (!shouldApplyDriverStoreFocus");
|
||||
const tabIdx = driverStore.indexOf('driverStoreTab.value = "agent"');
|
||||
assert.ok(applyIdx >= 0, "focus apply guard");
|
||||
assert.ok(tabIdx > applyIdx, "Agent tab switch must sit behind the one-shot focus guard");
|
||||
assert.match(driverStore, /showInstalledEmptyState/);
|
||||
assert.match(driverStore, /showAvailableEmptyState/);
|
||||
assert.match(driverStore, /driverStore\.noAvailableDrivers/);
|
||||
assert.doesNotMatch(driverStore, /selectStableDrivers\(categoryFilteredDrivers/);
|
||||
assert.doesNotMatch(driverStore, /selectUpdatableDrivers\(builtinDriverRows/);
|
||||
assert.equal([...driverStore.matchAll(/<DriverStoreAgentRow/g)].length, 3);
|
||||
});
|
||||
|
||||
test("driver rows show a text Installed badge beside the name", () => {
|
||||
const row = source("apps/desktop/src/components/config/DriverStoreAgentRow.vue");
|
||||
|
||||
assert.match(row, /driverStore\.installedBadge/);
|
||||
assert.match(row, /driver-store-installed-badge/);
|
||||
assert.match(row, /driver-store-agent-row--installed/);
|
||||
assert.match(row, /data-driver-store-focus/);
|
||||
assert.match(row, /driver-store-local-import-button/);
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "vitest";
|
||||
|
||||
/**
|
||||
* The shared editor swaps documents on tab switches (per-tab EditorState cache
|
||||
* since the undo-isolation fix). Both branches of that swap — the cached
|
||||
* `setState` path AND the uncached `swapEditorDocument` path (first activation
|
||||
* in this editor instance, or a tab evicted from the state cache) — must
|
||||
* restore the incoming tab's saved cursor and scroll position. Skipping the
|
||||
* restore on the uncached branch is what made switching back to a tab land on
|
||||
* line 1 instead of the previous viewport (#8374).
|
||||
*/
|
||||
function activateTabDocumentSource(): string {
|
||||
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
|
||||
const start = source.indexOf("function activateTabDocument");
|
||||
assert.notEqual(start, -1, "expected activateTabDocument in useQueryEditorDocumentState.ts");
|
||||
const end = source.indexOf("\n }", start);
|
||||
assert.notEqual(end, -1, "expected activateTabDocument closing brace");
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("uncached tab activation restores the saved cursor and scroll position", () => {
|
||||
const source = activateTabDocumentSource();
|
||||
assert.match(source, /if \(!cached\) \{[\s\S]*?swapEditorDocument\(doc\);[\s\S]*?restoreEditorSelection\([^;]*\);[\s\S]*?restoreEditorViewport\([^;]*\);[\s\S]*?return;/, "the swapEditorDocument branch must restore selection and viewport before returning");
|
||||
});
|
||||
|
||||
test("uncached tabs without saved state do not inherit the previous tab position", () => {
|
||||
const source = activateTabDocumentSource();
|
||||
assert.match(source, /restoreEditorSelection\(props\.initialSelection \?\? \{ anchor: 0, head: 0 \}, !props\.initialViewport\);/);
|
||||
assert.match(source, /restoreEditorViewport\(props\.initialViewport \?\? \{ scrollTop: 0, scrollLeft: 0 \}\);/);
|
||||
});
|
||||
|
||||
test("cached tab activation keeps restoring selection and viewport", () => {
|
||||
const source = activateTabDocumentSource();
|
||||
assert.match(source, /currentView\.setState\(cached\);/);
|
||||
assert.match(source, /currentView\.setState\(cached\);[\s\S]*?restoreEditorSelection\(undefined, !props\.initialViewport\);\s*restoreEditorViewport\(\);/);
|
||||
});
|
||||
|
||||
test("viewport restore prefers the per-tab saved viewport", () => {
|
||||
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
|
||||
assert.match(source, /function restoreEditorViewport\(viewport = props\.initialViewport \?\? latestViewport\) \{/);
|
||||
});
|
||||
|
||||
test("captures the outgoing editor viewport before KeepAlive deactivation", () => {
|
||||
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
|
||||
assert.match(source, /function captureEditorStateBeforeTabSwitch\(event: Event\) \{/);
|
||||
assert.match(source, /fromTabId !== props\.tabId/);
|
||||
assert.match(source, /captureEditorStateBeforeTabSwitch[\s\S]*?flushEditorViewport\(\);[\s\S]*?flushEditorSelection\(\);[\s\S]*?emit\("editorStateFlushed"\);/);
|
||||
assert.match(source, /window\.addEventListener\(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch\)/);
|
||||
assert.match(source, /window\.removeEventListener\(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch\)/);
|
||||
});
|
||||
|
||||
test("does not flush a reset viewport after a tab-switch capture", () => {
|
||||
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
|
||||
assert.match(source, /let tabSwitchStateCaptured = false;/);
|
||||
assert.match(source, /tabSwitchStateCaptured = true;/);
|
||||
assert.match(source, /const stateWasCapturedBeforeTabSwitch = tabSwitchStateCaptured;[\s\S]*?tabSwitchStateCaptured = false;[\s\S]*?if \(runtime\.editorIsActive && !stateWasCapturedBeforeTabSwitch\) \{[\s\S]*?flushEditorViewport\(\);/);
|
||||
assert.match(source, /if \(!tabSwitchStateCaptured\) flushEditorViewport\(\);/);
|
||||
});
|
||||
|
||||
test("does not flush an editor again when KeepAlive evicts it after deactivation", () => {
|
||||
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
|
||||
assert.match(source, /if \(runtime\.editorIsActive && !stateWasCapturedBeforeTabSwitch\) \{[\s\S]*?flushEditorViewport\(\);/);
|
||||
});
|
||||
|
||||
test("restores a saved cursor into view when no viewport was persisted", () => {
|
||||
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
|
||||
const editorSource = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
|
||||
assert.match(editorSource, /restoreEditorSelection\(props\.initialSelection, !props\.initialViewport\);/);
|
||||
assert.match(source, /function restoreEditorSelection\(selection = props\.initialSelection \?\? latestSelection, scrollIntoView = false\)/);
|
||||
assert.match(source, /view\.value\.dispatch\(\{ selection: normalizedSelection, scrollIntoView \}\);/);
|
||||
});
|
||||
|
||||
test("re-applies the viewport when saved SQL content hydrates after mount", () => {
|
||||
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
|
||||
assert.match(source, /watch\(\s*\(\) => props\.initialViewport,[\s\S]*?previousViewport[\s\S]*?latestViewport = \{ \.\.\.viewport \};[\s\S]*?restoreEditorViewport\(viewport\);/);
|
||||
assert.match(source, /watch\(\s*\(\) => props\.initialSelection,[\s\S]*?previousSelection[\s\S]*?restoreEditorSelection\(selection, !props\.initialViewport\);/);
|
||||
assert.match(source, /if \(attempts >= 32\)/);
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
const appPath = "apps/desktop/src/App.vue";
|
||||
const contractPath = "apps/desktop/src/components/layout/editorToolbarActions.ts";
|
||||
|
||||
// App.vue 是根组件,测试无法挂载;而这族回归(#8218,同 #8216)恰恰是
|
||||
// 「契约 tabId-first、实现 value-first」的 string/string 参数错位,结构化
|
||||
// 类型检查对此不可见,所以这里用源码钉住契约两侧的参数顺序。
|
||||
function functionSource(name: string): string {
|
||||
const source = readFileSync(appPath, "utf8");
|
||||
const start = source.indexOf(`function ${name}`);
|
||||
assert.ok(start >= 0, `App.vue should define ${name}`);
|
||||
const relativeEnd = source.slice(start).search(/\n(?:async\s+)?function\s+[A-Za-z]/);
|
||||
return source.slice(start, relativeEnd > 0 ? start + relativeEnd : undefined);
|
||||
}
|
||||
|
||||
test("toolbar context switchers resolve the acting tab from their first parameter (#8218)", () => {
|
||||
const signatures: Array<[name: string, expected: RegExp]> = [
|
||||
["changeActiveConnection", /^function changeActiveConnection\(tabId: string, connectionId: string\)/],
|
||||
["changeActiveDatabase", /^function changeActiveDatabase\(tabId: string, database: string\)/],
|
||||
["changeActiveCatalog", /^function changeActiveCatalog\(tabId: string, catalog: string \| undefined, database: string\)/],
|
||||
["changeActiveSchema", /^function changeActiveSchema\(tabId: string, schema: string \| undefined\)/],
|
||||
];
|
||||
for (const [name, expected] of signatures) {
|
||||
const body = functionSource(name);
|
||||
assert.match(body, expected);
|
||||
assert.match(body, /resolveToolbarTab\(tabId\)/);
|
||||
}
|
||||
});
|
||||
|
||||
test("App provides the switchers directly so the injected contract order reaches them", () => {
|
||||
const appSource = readFileSync(appPath, "utf8");
|
||||
for (const name of ["changeConnection", "changeCatalog", "changeDatabase", "changeSchema"]) {
|
||||
assert.match(appSource, new RegExp(`${name}: changeActive[A-Za-z]+,`));
|
||||
}
|
||||
});
|
||||
|
||||
test("the injected toolbar action contract keeps tab id as the first parameter", () => {
|
||||
const contract = readFileSync(contractPath, "utf8");
|
||||
assert.match(contract, /changeConnection\(tabId: string, connectionId: string\)/);
|
||||
assert.match(contract, /changeCatalog\(tabId: string, catalog: string \| undefined, database: string\)/);
|
||||
assert.match(contract, /changeDatabase\(tabId: string, database: string\)/);
|
||||
assert.match(contract, /changeSchema\(tabId: string, schema: string \| undefined\)/);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { elasticsearchJsonResponseForResult } from "../../apps/desktop/src/lib/elasticsearch/elasticsearchJsonResponse.ts";
|
||||
import type { QueryResult } from "../../apps/desktop/src/types/database.ts";
|
||||
@@ -96,12 +95,3 @@ test("uses the supplied result source statement to classify the response", () =>
|
||||
assert.equal(elasticsearchJsonResponseForResult("elasticsearch", "SELECT * FROM products", result), undefined);
|
||||
assert.equal(elasticsearchJsonResponseForResult("elasticsearch", undefined, result), undefined);
|
||||
});
|
||||
|
||||
test("routes focused find shortcuts to both Elasticsearch response-panel entry points", () => {
|
||||
const contentArea = readFileSync(new URL("../../apps/desktop/src/components/layout/ContentArea.vue", import.meta.url), "utf8");
|
||||
const focusSearch = contentArea.slice(contentArea.indexOf("function focusSearch("), contentArea.indexOf("function refreshData()"));
|
||||
|
||||
assert.match(focusSearch, /elasticsearchJsonResponsePanelRef\.value\?\.focusSearch\(\)/);
|
||||
assert.match(contentArea, /<ElasticsearchJsonResponsePanel v-if="activeElasticsearchJsonResponse" ref="elasticsearchJsonResponsePanelRef"/);
|
||||
assert.match(contentArea, /<ElasticsearchJsonResponsePanel v-else-if="showElasticsearchRawJson && activeElasticsearchRawBody" ref="elasticsearchJsonResponsePanelRef"/);
|
||||
});
|
||||
@@ -120,33 +120,3 @@ test("desktop SQL file opening is released when persisted tab restoration reject
|
||||
|
||||
assert.deepEqual(events, ["restore-tabs", "open-file"]);
|
||||
});
|
||||
|
||||
test("cold-start SQL files wait for restored tabs before opening", () => {
|
||||
const openPathStart = appSource.indexOf("async function openSqlFilePath");
|
||||
const openPathEnd = appSource.indexOf("async function openPendingSqlFiles", openPathStart);
|
||||
assert.ok(openPathStart >= 0 && openPathEnd > openPathStart);
|
||||
|
||||
const openPathSource = appSource.slice(openPathStart, openPathEnd);
|
||||
const initializationWait = openPathSource.indexOf("await desktopOpenTabsRestorationBarrier?.settled");
|
||||
const fileRead = openPathSource.indexOf("api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb))");
|
||||
const tabOpen = openPathSource.indexOf("queryStore.openExternalSqlFile");
|
||||
assert.ok(initializationWait >= 0);
|
||||
assert.ok(initializationWait < fileRead);
|
||||
assert.ok(fileRead < tabOpen);
|
||||
|
||||
const mountedStart = appSource.indexOf("onMounted(async () =>");
|
||||
const mountedEnd = appSource.indexOf("onUnmounted(", mountedStart);
|
||||
assert.ok(mountedStart >= 0 && mountedEnd > mountedStart);
|
||||
|
||||
const mountedSource = appSource.slice(mountedStart, mountedEnd);
|
||||
const barrierCreation = mountedSource.indexOf("desktopOpenTabsRestorationBarrier = createOpenTabsRestorationBarrier()");
|
||||
const initializationStart = mountedSource.indexOf("void initApp()", barrierCreation);
|
||||
const listenerSetup = mountedSource.indexOf("setupTauriListeners()", initializationStart);
|
||||
const pendingFileOpen = mountedSource.indexOf("openPendingSqlFiles()");
|
||||
assert.ok(barrierCreation >= 0);
|
||||
assert.ok(initializationStart >= 0);
|
||||
assert.ok(barrierCreation < initializationStart);
|
||||
assert.ok(initializationStart < listenerSetup);
|
||||
assert.ok(listenerSetup < pendingFileOpen);
|
||||
assert.ok(initializationStart < pendingFileOpen);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
import { clampGridFsPage, gridFsTotalPages, paginateGridFsItems } from "../../apps/desktop/src/lib/document/gridfsPagination.ts";
|
||||
@@ -12,20 +11,3 @@ test("paginates GridFS files with a clamped page index", () => {
|
||||
assert.deepEqual(paginateGridFsItems(files, 1, 2).map((file) => file.id), ["f3", "f4"]);
|
||||
assert.deepEqual(paginateGridFsItems(files, 9, 2).map((file) => file.id), ["f5"]);
|
||||
});
|
||||
|
||||
test("GridFS browsers keep a scrollable flex layout", () => {
|
||||
const bucketBrowser = readFileSync("apps/desktop/src/components/document/MongoBucketBrowser.vue", "utf8");
|
||||
const gridfsBrowser = readFileSync("apps/desktop/src/components/document/MongoGridFsBrowser.vue", "utf8");
|
||||
|
||||
assert.match(bucketBrowser, /class="flex h-full min-h-0 flex-col overflow-hidden"/);
|
||||
assert.match(bucketBrowser, /v-else class="min-h-0 flex flex-1 flex-col overflow-hidden xl:flex-row/);
|
||||
assert.match(gridfsBrowser, /class="flex h-full min-h-0 flex-col overflow-hidden"/);
|
||||
assert.match(gridfsBrowser, /v-else class="min-h-0 flex flex-1 flex-col overflow-hidden xl:flex-row/);
|
||||
});
|
||||
|
||||
test("GridFS file browser keeps metadata columns compact on a single line", () => {
|
||||
const bucketBrowser = readFileSync("apps/desktop/src/components/document/MongoBucketBrowser.vue", "utf8");
|
||||
|
||||
assert.match(bucketBrowser, /<table class="min-w-full border-collapse text-\[13px\]">/);
|
||||
assert.match(bucketBrowser, /whitespace-nowrap tabular-nums/);
|
||||
});
|
||||
-43
@@ -1,50 +1,7 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { DEFAULT_HBASE_ROW_LIMIT, HBASE_ROW_LIMIT_STORAGE_KEY, loadHBaseRowLimit, normalizeHBaseRowLimit, saveHBaseRowLimit } from "../../apps/desktop/src/lib/hbase/hbaseBrowserPreferences.ts";
|
||||
|
||||
function functionBody(source: string, name: string): string {
|
||||
const signatureIndex = source.indexOf(`async function ${name}(`);
|
||||
assert.notEqual(signatureIndex, -1, `Could not find function ${name}`);
|
||||
const bodyStart = source.indexOf("{", signatureIndex);
|
||||
let depth = 0;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
if (source[index] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart + 1, index);
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not parse function ${name}`);
|
||||
}
|
||||
|
||||
test("HBase table DDL refreshes the visible object list instead of reusing loaded children", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/hbase/HBaseBrowser.vue", "utf8");
|
||||
|
||||
for (const name of ["createTable", "deleteTable"]) {
|
||||
const body = functionBody(source, name);
|
||||
assert.match(body, /await connectionStore\.refreshObjectListTreeNode\(props\.connectionId, props\.namespace\)/);
|
||||
assert.doesNotMatch(body, /connectionStore\.loadTables/);
|
||||
}
|
||||
});
|
||||
|
||||
test("HBase grid disables unsupported offset and infinite-scroll pagination", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/hbase/HBaseBrowser.vue", "utf8");
|
||||
assert.match(source, /<DataGrid[\s\S]*:pagination-enabled="false"/);
|
||||
});
|
||||
|
||||
test("HBase table context menu exposes its REST-backed delete action", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const menuStart = source.indexOf('if (currentDatabaseType() === "hbase" && node.type === "table")');
|
||||
const menuEnd = source.indexOf("return true;", menuStart);
|
||||
const menu = source.slice(menuStart, menuEnd);
|
||||
|
||||
assert.match(menu, /label: t\("hbase\.deleteTable"\)/);
|
||||
assert.match(menu, /action: requestDeleteHBaseTable/);
|
||||
assert.match(source, /await api\.hbaseDeleteTable\(node\.connectionId, node\.database, node\.label\)/);
|
||||
assert.match(source, /await connectionStore\.refreshObjectListTreeNode\(node\.connectionId, node\.database\)/);
|
||||
});
|
||||
|
||||
test("HBase row scan limit is normalized and persisted", () => {
|
||||
const previousDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const values = new Map<string, string>();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { buildMongoSyntaxDiagnostics, shouldRunMongoDiagnostics } from "../../apps/desktop/src/lib/mongo/mongoSyntaxDiagnostics";
|
||||
|
||||
@@ -122,9 +121,4 @@ test("keeps completed diagnostics while the cursor is in an unfinished command",
|
||||
|
||||
const unfinished = underlined(source, 0).find((diagnostic) => diagnostic.severity === "error");
|
||||
assert.match(unfinished!.message, /unclosed/i);
|
||||
|
||||
const diagnosticsSource = readFileSync("apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts", "utf8");
|
||||
const mongoBranch = diagnosticsSource.slice(diagnosticsSource.indexOf('if (props.databaseType === "mongodb")'), diagnosticsSource.indexOf('if (props.databaseType === "redis")'));
|
||||
assert.doesNotMatch(mongoBranch, /shouldRunMongoDiagnostics/);
|
||||
assert.match(mongoBranch, /setSemanticDiagnostics\(buildMongoSyntaxDiagnostics\(sql, cursor\)\)/);
|
||||
});
|
||||
-12
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { decodePayload, encodePayload } from "@/lib/mqtt/mqttPayloadCodec";
|
||||
|
||||
@@ -49,18 +48,7 @@ test("MQTT empty payloads remain empty", () => {
|
||||
assert.equal(decodePayload("", "plaintext"), "");
|
||||
});
|
||||
|
||||
test("MQTT JSON publishing sends canonical bytes instead of the original text", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/mqtt/MqttPublishDialog.vue", "utf8");
|
||||
assert.match(source, /payloadText:\s*encoding\.value === "plaintext" \? payloadText\.value : null/);
|
||||
assert.doesNotMatch(source, /encoding\.value === "plaintext" \|\| encoding\.value === "json"/);
|
||||
});
|
||||
|
||||
test("MQTT 中文 JSON Payload 保留 UTF-8 文本且不添加换行符", () => {
|
||||
const encoded = encodePayload('{\n "消息": "温度正常",\n "设备": "客厅传感器"\n}', "json");
|
||||
assert.equal(decodePayload(encoded, "plaintext"), '{"消息":"温度正常","设备":"客厅传感器"}');
|
||||
});
|
||||
test("MQTT JSON placeholder uses a named interpolation for literal braces", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/mqtt/MqttPublishDialog.vue", "utf8");
|
||||
assert.match(source, /mqttPayloadPlaceholderJson", \{ example:/);
|
||||
assert.doesNotMatch(source, /mqttPayloadPlaceholderJson"\);/);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { filterObjectBrowserTableColumns } from "../../apps/desktop/src/lib/table/objectBrowserTableInfo.ts";
|
||||
|
||||
@@ -47,20 +46,3 @@ test("returns every column for empty searches and preserves match order", () =>
|
||||
["note", "empty_note"],
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the data-grid table properties search on the shared column filter", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
|
||||
assert.match(source, /filterObjectBrowserTableColumns\(tableInfoColumns\.value, searchQuery\.value\)/);
|
||||
});
|
||||
|
||||
test("both table DDL surfaces use the persisted wrapping preference", () => {
|
||||
const sources = ["apps/desktop/src/components/grid/DataGrid.vue", "apps/desktop/src/components/objects/ObjectBrowser.vue"].map((path) => readFileSync(path, "utf8"));
|
||||
|
||||
for (const source of sources) {
|
||||
assert.doesNotMatch(source, /const (?:ddlWrap|tableInfoWrap) = ref\(true\)/);
|
||||
assert.match(source, /:class="\{ 'bg-accent': settingsStore\.editorSettings\.tableDdlWordWrap \}"/);
|
||||
assert.match(source, /:class="settingsStore\.editorSettings\.tableDdlWordWrap \? 'whitespace-pre-wrap break-words' : 'whitespace-pre'"/);
|
||||
assert.match(source, /settingsStore\.updateEditorSettings\(\{\s*tableDdlWordWrap: !settingsStore\.editorSettings\.tableDdlWordWrap,?\s*\}\)/);
|
||||
}
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
function read(relativePath: string): string {
|
||||
return readFileSync(relativePath, "utf8");
|
||||
}
|
||||
|
||||
function functionBody(source: string, name: string): string {
|
||||
const signature = `function ${name}(`;
|
||||
const asyncSignature = `async ${signature}`;
|
||||
const signatureIndex = source.indexOf(asyncSignature) >= 0 ? source.indexOf(asyncSignature) : source.indexOf(signature);
|
||||
assert.notEqual(signatureIndex, -1, `Could not find function ${name}`);
|
||||
const bodyStart = source.indexOf("{", signatureIndex);
|
||||
assert.notEqual(bodyStart, -1, `Could not find body for ${name}`);
|
||||
|
||||
let depth = 0;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === "{") depth += 1;
|
||||
if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart + 1, index);
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not parse body for ${name}`);
|
||||
}
|
||||
|
||||
// issue #9035:打开对象源码必须「先出 UI 再加载」。加载态要有可见落点
|
||||
// (tab 栏 + tab 内容区)、失败要能就地重试、连接建立也要罩在弹窗的加载态之内。
|
||||
test("object source loading feedback is visible in the tab bar and the tab body", () => {
|
||||
const contentArea = read("apps/desktop/src/components/layout/ContentArea.vue");
|
||||
const tabStatus = read("apps/desktop/src/components/layout/TabExecutionStatus.vue");
|
||||
|
||||
assert.match(contentArea, /activeTab\.sourceLoad && !activeTab\.sourceLoad\.error/);
|
||||
assert.match(contentArea, /:elapsed-seconds="sourceLoadElapsedSeconds"/);
|
||||
assert.match(contentArea, /data-object-source-load-error/);
|
||||
assert.match(contentArea, /queryStore\.retryObjectSourceTab\(activeTab\.id\)/);
|
||||
|
||||
assert.match(tabStatus, /props\.tab\.sourceLoad && !props\.tab\.sourceLoad\.error/);
|
||||
// 失败态由内容区的错误 + Retry 表达,tab 栏不应继续转圈
|
||||
assert.match(tabStatus, /labelKey/);
|
||||
});
|
||||
|
||||
test("the object source dialog owns the connection it waits for", () => {
|
||||
const dialog = read("apps/desktop/src/components/objects/ObjectSourceDialog.vue");
|
||||
const loadBody = functionBody(dialog, "loadSource");
|
||||
|
||||
// 调用方不再先 await ensureConnected 才开弹窗,连接必须发生在弹窗的 loading 之内
|
||||
const connectIndex = loadBody.indexOf("ensureConnected");
|
||||
const fetchIndex = loadBody.indexOf("loadObjectSourceWithRoutineFallback");
|
||||
assert.ok(connectIndex >= 0, "ObjectSourceDialog.loadSource must own ensureConnected");
|
||||
assert.ok(fetchIndex >= 0, "ObjectSourceDialog.loadSource must fetch the source");
|
||||
assert.ok(connectIndex < fetchIndex, "connect must happen before the source fetch");
|
||||
});
|
||||
|
||||
test("query-tab entry no longer blocks the sidebar on connect", () => {
|
||||
const connectionTree = read("apps/desktop/src/components/sidebar/ConnectionTree.vue");
|
||||
const openBody = functionBody(connectionTree, "openSidebarObjectSource");
|
||||
|
||||
// 弹窗立即挂载;此前的 await ensureConnected 制造了没有反馈的死等窗口
|
||||
// (断言调用形态,注释里提到这个名字不算)
|
||||
assert.doesNotMatch(openBody, /ensureConnected\(/);
|
||||
assert.match(openBody, /sidebarObjectSourceOpen\.value = true/);
|
||||
});
|
||||
|
||||
test("query-editor object source entry uses the same visible loading path", () => {
|
||||
const app = read("apps/desktop/src/App.vue");
|
||||
const openBody = functionBody(app, "onOpenObjectSource");
|
||||
|
||||
assert.match(openBody, /queryStore\.openObjectSourceTabPending\(\{/);
|
||||
assert.match(openBody, /queryEditorObjectSourceTarget\.value =/);
|
||||
assert.match(openBody, /showQueryEditorObjectSourceDialog\.value = true/);
|
||||
assert.doesNotMatch(openBody, /ensureConnected\(/);
|
||||
assert.doesNotMatch(openBody, /api\.getObjectSource\(/);
|
||||
});
|
||||
|
||||
test("object source load state is runtime-only and never restored from disk", () => {
|
||||
const persistence = read("apps/desktop/src/lib/app/openTabsPersistence.ts");
|
||||
const types = read("apps/desktop/src/types/database.ts");
|
||||
const serializeBody = functionBody(persistence, "serializeOpenTabs");
|
||||
|
||||
// tab 类型上带着重试所需的请求身份
|
||||
assert.match(types, /sourceLoad\?: \{/);
|
||||
assert.match(types, /request: \{\n\s+name: string;\n\s+objectType: ObjectSourceKind;/);
|
||||
// 不落盘:否则重启后 tab 会永久停在「加载中」
|
||||
assert.doesNotMatch(serializeBody, /sourceLoad/);
|
||||
assert.match(persistence, /sourceLoad: undefined/);
|
||||
});
|
||||
-13
@@ -1,6 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, vi } from "vitest";
|
||||
import {
|
||||
isPhoenixRuntimePath,
|
||||
@@ -245,14 +243,3 @@ test("does not provision Phoenix runtimes for another JDBC profile", async () =>
|
||||
assert.equal(runtime.jdbcPluginStatus.mock.calls.length, 0);
|
||||
assert.equal(runtime.listJdbcMavenBundles.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
test("registers Apache Phoenix as a declarative JDBC picker profile with both modes", () => {
|
||||
const profileSource = readFileSync(path.resolve("apps/desktop/src/lib/database/phoenixConnection.ts"), "utf8");
|
||||
const registrySource = readFileSync(path.resolve("apps/desktop/src/lib/database/jdbcProductProfiles.ts"), "utf8");
|
||||
|
||||
assert.match(profileSource, /id:\s*PHOENIX_DRIVER_PROFILE/);
|
||||
assert.match(profileSource, /match:\s*\{\s*dbType:\s*"jdbc",\s*driverProfile:\s*PHOENIX_DRIVER_PROFILE\s*\}/);
|
||||
assert.match(profileSource, /id:\s*"direct"/);
|
||||
assert.match(profileSource, /id:\s*"query-server"/);
|
||||
assert.match(registrySource, /JDBC_PRODUCT_PROFILES\s*=\s*\[PHOENIX_JDBC_PRODUCT_PROFILE\]/);
|
||||
});
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "vitest";
|
||||
|
||||
function source(relativePath: string): string {
|
||||
return readFileSync(path.resolve(relativePath), "utf8");
|
||||
}
|
||||
|
||||
test("renders registered managed JDBC rows in Driver Manager", () => {
|
||||
const driverStore = source("apps/desktop/src/components/config/DriverStoreDialog.vue");
|
||||
|
||||
assert.match(driverStore, /managedJdbcDriverRows\(jdbcMavenBundles\.value, jdbcPluginStatus\.value\)/);
|
||||
assert.match(driverStore, /isManagedJdbcBuiltinDriver\(dbType: string\)/);
|
||||
assert.match(driverStore, /isManagedJdbcDriver\(dbType\)/);
|
||||
});
|
||||
|
||||
test("dispatches install and uninstall through the shared managed JDBC lifecycle", () => {
|
||||
const driverStore = source("apps/desktop/src/components/config/DriverStoreDialog.vue");
|
||||
|
||||
assert.match(driverStore, /installRegisteredManagedJdbcDriver\(dbType, jdbcMavenBundles\.value, jdbcPluginStatus\.value, api\)/);
|
||||
assert.match(driverStore, /uninstallRegisteredManagedJdbcDriver\(dbType, jdbcMavenBundles\.value, api\)/);
|
||||
});
|
||||
|
||||
test("routes managed product runtime failures back to the registered Driver Manager row", () => {
|
||||
const connectionDialog = source("apps/desktop/src/components/connection/ConnectionDialog.vue");
|
||||
|
||||
assert.match(connectionDialog, /connectionErrorRawDetail\.value = message/);
|
||||
assert.match(connectionDialog, /isRegisteredJdbcProductRuntimeInstallError\(form\.value, connectionErrorRawDetail\.value\)/);
|
||||
assert.match(connectionDialog, /isJdbcProductConnection\.value \? agentDriverFocus\.value : \{ target: "tab", tab: "jdbc" \}/);
|
||||
assert.match(connectionDialog, /emit\('openDriverStore', agentDriverFocus\)/);
|
||||
});
|
||||
|
||||
test("treats each registered product mode as one complete runtime in the connection selector", () => {
|
||||
const connectionDialog = source("apps/desktop/src/components/connection/ConnectionDialog.vue");
|
||||
|
||||
assert.match(connectionDialog, /jdbcProductManagedRuntimePaths\(productProfile, jdbcMavenBundles\.value, productMode\.id\)/);
|
||||
assert.match(connectionDialog, /label: t\(productProfile\.runtimeLabelKey/);
|
||||
assert.match(connectionDialog, /!isJdbcProductManagedMavenPath\(activeJdbcProductProfile\.value!, path\)/);
|
||||
assert.doesNotMatch(connectionDialog, /async function loadJdbcDrivers\(\) \{\s*if \(!isDesktop\) return;/);
|
||||
});
|
||||
|
||||
test("keeps product-specific branches out of generic integration files", () => {
|
||||
for (const file of ["apps/desktop/src/components/connection/ConnectionDialog.vue", "apps/desktop/src/components/config/DriverStoreDialog.vue", "apps/desktop/src/App.vue"]) {
|
||||
const contents = source(file);
|
||||
assert.doesNotMatch(contents, /from ["']@\/lib\/database\/phoenix/);
|
||||
assert.doesNotMatch(contents, /PHOENIX_DRIVER_PROFILE/);
|
||||
assert.doesNotMatch(contents, /isPhoenix|ensurePhoenix|phoenixConnectionMode/);
|
||||
}
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import az from "../../apps/desktop/src/i18n/locales/az.ts";
|
||||
import en from "../../apps/desktop/src/i18n/locales/en.ts";
|
||||
import es from "../../apps/desktop/src/i18n/locales/es.ts";
|
||||
import it from "../../apps/desktop/src/i18n/locales/it.ts";
|
||||
import ja from "../../apps/desktop/src/i18n/locales/ja.ts";
|
||||
import ko from "../../apps/desktop/src/i18n/locales/ko.ts";
|
||||
import tr from "../../apps/desktop/src/i18n/locales/tr.ts";
|
||||
import ptBR from "../../apps/desktop/src/i18n/locales/pt-BR.ts";
|
||||
import zhCN from "../../apps/desktop/src/i18n/locales/zh-CN.ts";
|
||||
import zhTW from "../../apps/desktop/src/i18n/locales/zh-TW.ts";
|
||||
|
||||
test("every locale defines the PostgreSQL legacy TLS labels", () => {
|
||||
const locales = { az, en, es, it, ja, ko, ptBR, tr, zhCN, zhTW } as const;
|
||||
|
||||
for (const [localeName, locale] of Object.entries(locales)) {
|
||||
assert.ok(locale.connection.postgresLegacyTls.length > 0, `${localeName}: connection.postgresLegacyTls`);
|
||||
assert.ok(locale.connection.postgresLegacyTlsHint.length > 0, `${localeName}: connection.postgresLegacyTlsHint`);
|
||||
}
|
||||
});
|
||||
-76
@@ -1,7 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { compileScript, compileTemplate, parse } from "vue/compiler-sfc";
|
||||
import az from "../../apps/desktop/src/i18n/locales/az.ts";
|
||||
import en from "../../apps/desktop/src/i18n/locales/en.ts";
|
||||
import es from "../../apps/desktop/src/i18n/locales/es.ts";
|
||||
@@ -13,80 +11,6 @@ import ptBR from "../../apps/desktop/src/i18n/locales/pt-BR.ts";
|
||||
import zhCN from "../../apps/desktop/src/i18n/locales/zh-CN.ts";
|
||||
import zhTW from "../../apps/desktop/src/i18n/locales/zh-TW.ts";
|
||||
|
||||
const messagesViewPath = "apps/desktop/src/components/layout/QueryMessagesView.vue";
|
||||
const viewSwitcherPath = "apps/desktop/src/components/layout/QueryResultViewSwitcher.vue";
|
||||
const contentAreaPath = "apps/desktop/src/components/layout/ContentArea.vue";
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
test("QueryMessagesView SFC compiles", () => {
|
||||
const { descriptor, errors } = parse(source(messagesViewPath), { filename: messagesViewPath });
|
||||
assert.deepEqual(errors, [], `${messagesViewPath} should parse without SFC errors`);
|
||||
assert.ok(descriptor.scriptSetup, `${messagesViewPath} should have a script setup block`);
|
||||
compileScript(descriptor, { id: messagesViewPath });
|
||||
assert.ok(descriptor.template);
|
||||
const result = compileTemplate({ id: messagesViewPath, filename: messagesViewPath, source: descriptor.template!.content });
|
||||
assert.deepEqual(result.errors, [], `${messagesViewPath} template should compile`);
|
||||
});
|
||||
|
||||
test("QueryMessagesView maps severities to badge tones", () => {
|
||||
const view = source(messagesViewPath);
|
||||
|
||||
// error/fatal/panic are errors, anything containing "warn" is a warning,
|
||||
// everything else (NOTICE, INFO, Note, ...) stays muted.
|
||||
assert.match(view, /normalized === "error" \|\| normalized === "fatal" \|\| normalized === "panic"\) return "error"/);
|
||||
assert.match(view, /normalized\.includes\("warn"\)\) return "warning"/);
|
||||
assert.match(view, /return "muted"/);
|
||||
for (const tone of ["muted", "warning", "error"] as const) {
|
||||
assert.match(view, new RegExp(`${tone}: "`));
|
||||
}
|
||||
});
|
||||
|
||||
test("QueryMessagesView renders message text, extras, and the empty state", () => {
|
||||
const view = source(messagesViewPath);
|
||||
|
||||
assert.match(view, /t\("queryMessages\.empty"\)/);
|
||||
assert.match(view, /\{\{ message\.severity \}\}/);
|
||||
assert.match(view, /\{\{ message\.message \}\}/);
|
||||
assert.match(view, /v-if="message\.detail"/);
|
||||
assert.match(view, /v-if="message\.hint"/);
|
||||
assert.match(view, /v-if="message\.code"[\s\S]*t\("queryMessages\.code", \{ code: message\.code \}\)/);
|
||||
});
|
||||
|
||||
test("view switcher exposes a messages button with a count badge", () => {
|
||||
const switcher = source(viewSwitcherPath);
|
||||
|
||||
assert.match(switcher, /canShowMessages: boolean/);
|
||||
assert.match(switcher, /messageCount\?: number/);
|
||||
assert.match(switcher, /<MessageSquareText class="block h-3\.5 w-3\.5 self-center" \/>/);
|
||||
assert.match(switcher, /:disabled="!canShowMessages"/);
|
||||
assert.match(switcher, /@click="selectView\('messages'\)"/);
|
||||
assert.match(switcher, /v-if="!compact && messageCount > 0"[\s\S]*?\{\{ messageCount \}\}/);
|
||||
// The tooltip/aria label carries the count when messages exist.
|
||||
assert.match(switcher, /messagesTooltip = computed\(\(\) => \(props\.messageCount > 0/);
|
||||
});
|
||||
|
||||
test("ContentArea wires server messages into the switcher and the messages view", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
|
||||
// Messages are aggregated across every statement result of the run so a
|
||||
// message-only statement (e.g. a PostgreSQL DO block) keeps its notices even
|
||||
// when a later statement owns the active result.
|
||||
assert.match(contentArea, /const resultMessages = computed<QueryMessage\[\]>\(\(\) => \{/);
|
||||
assert.match(contentArea, /props\.activeTab\.results\?\.length \? props\.activeTab\.results : props\.activeTab\.result \? \[props\.activeTab\.result\] : \[\]/);
|
||||
assert.match(contentArea, /results\.flatMap\(queryResultMessages\)/);
|
||||
assert.match(contentArea, /resultMessageCount = computed\(\(\) => resultMessages\.value\.length\)/);
|
||||
assert.match(contentArea, /canShowMessagesOutput = computed\(\(\) => resultMessageCount\.value > 0\)/);
|
||||
assert.equal((contentArea.match(/:can-show-messages="canShowMessagesOutput"/g) ?? []).length, 2);
|
||||
assert.equal((contentArea.match(/:message-count="resultMessageCount"/g) ?? []).length, 2);
|
||||
assert.match(contentArea, /<QueryMessagesView v-else-if="activeOutputView === 'messages'"[\s\S]*:messages="resultMessages"/);
|
||||
// Results with no result set auto-switch via the shared default-view helper.
|
||||
assert.match(contentArea, /import \{ defaultViewForResult \} from "@\/lib\/query\/queryResultDefaultView"/);
|
||||
assert.match(contentArea, /emit\("update:activeOutputView", props\.activeTab\.id, result \? defaultViewForResult\(result\) : "summary"\)/);
|
||||
});
|
||||
|
||||
test("every locale defines the query message strings", () => {
|
||||
const locales = { az, en, es, it, ja, ko, "pt-BR": ptBR, tr, "zh-CN": zhCN, "zh-TW": zhTW };
|
||||
|
||||
|
||||
-253
@@ -1,269 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { compileScript, compileTemplate, parse } from "vue/compiler-sfc";
|
||||
|
||||
const contentAreaPath = "apps/desktop/src/components/layout/ContentArea.vue";
|
||||
const resultSetNavigatorPath = "apps/desktop/src/components/layout/ResultSetNavigator.vue";
|
||||
const appPath = "apps/desktop/src/App.vue";
|
||||
const dataGridPath = "apps/desktop/src/components/grid/DataGrid.vue";
|
||||
const dataGridExportMenuPath = "apps/desktop/src/components/grid/DataGridExportMenu.vue";
|
||||
const dataGridToolbarPath = "apps/desktop/src/components/grid/DataGridToolbar.vue";
|
||||
const editorToolbarPath = "apps/desktop/src/components/layout/EditorToolbar.vue";
|
||||
const viewSwitcherPath = "apps/desktop/src/components/layout/QueryResultViewSwitcher.vue";
|
||||
const toolbarActionsPath = "apps/desktop/src/components/layout/QueryResultToolbarActions.vue";
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
function assertSfcCompiles(path: string): void {
|
||||
const { descriptor, errors } = parse(source(path), { filename: path });
|
||||
assert.deepEqual(errors, [], `${path} should parse without SFC errors`);
|
||||
assert.ok(descriptor.scriptSetup, `${path} should have a script setup block`);
|
||||
compileScript(descriptor, { id: path });
|
||||
if (descriptor.template) {
|
||||
const result = compileTemplate({ id: path, filename: path, source: descriptor.template.content });
|
||||
assert.deepEqual(result.errors, [], `${path} template should compile`);
|
||||
}
|
||||
}
|
||||
|
||||
test("query result toolbar SFCs compile", () => {
|
||||
for (const path of [contentAreaPath, dataGridPath, dataGridExportMenuPath, dataGridToolbarPath, editorToolbarPath, viewSwitcherPath, toolbarActionsPath]) assertSfcCompiles(path);
|
||||
});
|
||||
|
||||
test("ContentArea keeps query-result insert and delete capabilities separate", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
|
||||
assert.match(contentArea, /:allow-insert-rows="activeTab\.queryAnalysis\?\.allowInsert \?\? activeTab\.queryAnalysis\?\.allowInsertDelete !== false"/);
|
||||
assert.match(contentArea, /:allow-delete-rows="activeTab\.queryAnalysis\?\.allowDelete \?\? activeTab\.queryAnalysis\?\.allowInsertDelete !== false"/);
|
||||
});
|
||||
|
||||
test("query result toolbar reuses the production icon contract", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
const dataGrid = source(dataGridPath);
|
||||
const dataGridExportMenu = source(dataGridExportMenuPath);
|
||||
const dataGridToolbar = source(dataGridToolbarPath);
|
||||
const editorToolbar = source(editorToolbarPath);
|
||||
const viewSwitcher = source(viewSwitcherPath);
|
||||
const toolbarActions = source(toolbarActionsPath);
|
||||
|
||||
assert.match(contentArea, /<Pin class="h-3\.5 w-3\.5"/);
|
||||
assert.match(contentArea, /<Wrench class="h-4 w-4"/);
|
||||
assert.match(contentArea, /<ChevronDown class="h-3\.5 w-3\.5"/);
|
||||
assert.match(viewSwitcher, /import \{ BarChart3, ListChecks, MessageSquareText \} from "@lucide\/vue"/);
|
||||
assert.match(toolbarActions, /import \{ GitBranch, Gauge, Loader2, PlugZap, Upload \} from "@lucide\/vue"/);
|
||||
assert.match(editorToolbar, /@click="emit\('importResultArchive'\)"[\s\S]{0,100}<Download/);
|
||||
assert.match(toolbarActions, /@click="emit\('exportArchive'\)"[\s\S]{0,200}<Upload v-else/);
|
||||
assert.match(dataGrid, /return \{ label: t\("grid\.export"\), icon: Upload, children: items \};/);
|
||||
assert.match(dataGridExportMenu, /:trigger-icon="Upload"/);
|
||||
assert.match(dataGridToolbar, /<Upload class="data-grid-topbar-action-icon h-3 w-3"/);
|
||||
assert.match(viewSwitcher, /inline-flex h-4 items-center leading-none/);
|
||||
assert.match(toolbarActions, /block h-3\.5 w-3\.5 self-center/);
|
||||
assert.doesNotMatch(viewSwitcher + toolbarActions, /<svg\b|<symbol\b|<use\b/);
|
||||
});
|
||||
|
||||
test("ContentArea exposes retained result runs as switchable tabs or a compact list", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
const runScrollerStart = contentArea.indexOf('ref="resultTabsScrollerRef"');
|
||||
const listSelectorStart = contentArea.indexOf('<div v-else-if="showResultRunSelector"', runScrollerStart);
|
||||
const fixedResultSetStart = contentArea.indexOf("<ResultSetNavigator", listSelectorStart);
|
||||
|
||||
assert.match(contentArea, /showResultRunTabs = computed\(\(\) => resultRuns\.value\.length > 0 && resultRunDisplayMode\.value === "tabs"\)/);
|
||||
assert.match(contentArea, /showResultRunSelector = computed\(\(\) => resultRuns\.value\.length > 0 && resultRunDisplayMode\.value === "list"\)/);
|
||||
assert.match(contentArea, /ref="resultTabsScrollerRef"[\s\S]*v-for="\(run, runIndex\) in resultRuns"/);
|
||||
assert.match(contentArea, /role="tablist" :aria-label="t\('tabs\.resultRuns'\)"/);
|
||||
assert.match(contentArea, /data-result-run-tab/);
|
||||
assert.match(contentArea, /@keydown="onResultRunTabKeydown\(\$event, runIndex\)"/);
|
||||
assert.match(contentArea, /@click\.stop\.prevent="removeResultRun\(run\.id\)"/);
|
||||
assert.match(contentArea, /const canCloseQueryResult = computed\([\s\S]*!props\.activeTab\.activeResultRunId/);
|
||||
assert.match(contentArea, /v-if="canCloseQueryResult"[\s\S]*@click="closeCurrentQueryResult"/);
|
||||
assert.match(contentArea, /queryStore\.closeQueryResult\(props\.activeTab\.id\)/);
|
||||
assert.match(contentArea, /t\('tabs\.closeResult'\)/);
|
||||
assert.match(contentArea, /<DropdownMenuContent align="start" class="w-48">[\s\S]*v-for="run in resultRuns"/);
|
||||
assert.match(contentArea, /setResultRunDisplayMode\('list'\)/);
|
||||
assert.match(contentArea, /setResultRunDisplayMode\('tabs'\)/);
|
||||
assert.ok(runScrollerStart >= 0);
|
||||
assert.ok(listSelectorStart > runScrollerStart);
|
||||
assert.ok(fixedResultSetStart > listSelectorStart);
|
||||
assert.match(source(resultSetNavigatorPath), /data-result-set-tabs-region/);
|
||||
assert.doesNotMatch(contentArea.slice(runScrollerStart, listSelectorStart), /visibleResultItems/);
|
||||
assert.match(contentArea, /resultAutoSave \? 'bg-primary\/10 text-primary[\s\S]*: 'text-muted-foreground hover:bg-accent hover:text-foreground'/);
|
||||
assert.doesNotMatch(contentArea, /queryResultAutoRefresh|QUERY_RESULT_AUTO_REFRESH|nextResultToolbarLayout/);
|
||||
assert.equal((contentArea.match(/<QueryResultViewSwitcher\b/g) ?? []).length, 2);
|
||||
assert.equal((contentArea.match(/<QueryResultToolbarActions\b/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test("appending a result run preserves the tab-strip scroll position", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
const resultRunWatcherStart = contentArea.indexOf("function resultRunIdsWereAppended");
|
||||
const resultRunWatcherEnd = contentArea.indexOf("const summaryItems", resultRunWatcherStart);
|
||||
const resultRunWatcher = contentArea.slice(resultRunWatcherStart, resultRunWatcherEnd);
|
||||
|
||||
assert.ok(resultRunWatcherStart >= 0);
|
||||
assert.match(resultRunWatcher, /current\.length > previous\.length/);
|
||||
assert.match(resultRunWatcher, /activeRunId: props\.activeTab\.activeResultRunId/);
|
||||
assert.match(resultRunWatcher, /activeRunChanged && !resultRunIdsWereAppended\(previous\.runIds, current\.runIds\)/);
|
||||
assert.match(resultRunWatcher, /updateResultTabsAfterRender/);
|
||||
assert.match(resultRunWatcher, /revealActiveResultRunAfterRender/);
|
||||
assert.match(contentArea, /function focusResultRunByIndex[\s\S]*scrollIntoView/);
|
||||
assert.match(contentArea, /async function removeResultRun[\s\S]*scrollIntoView/);
|
||||
});
|
||||
|
||||
test("the close-tab shortcut clears query results before closing the tab", () => {
|
||||
const app = source(appPath);
|
||||
const closeSurfaceStart = app.indexOf("async function closeActiveSurface()");
|
||||
const closeSurfaceEnd = app.indexOf("function activateMainContentSurface", closeSurfaceStart);
|
||||
const closeSurface = app.slice(closeSurfaceStart, closeSurfaceEnd);
|
||||
|
||||
assert.ok(closeSurfaceStart >= 0);
|
||||
assert.ok(closeSurface.indexOf("await queryStore.clearQueryResults(queryStore.activeTabId)") < closeSurface.indexOf("queryStore.closeTab(queryStore.activeTabId)"));
|
||||
});
|
||||
|
||||
test("the configurable results pane shortcut only toggles existing output", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
const app = source(appPath);
|
||||
const toggleStart = contentArea.indexOf("function toggleResultsPane()");
|
||||
const toggleEnd = contentArea.indexOf("function onResultsResized", toggleStart);
|
||||
const toggle = contentArea.slice(toggleStart, toggleEnd);
|
||||
const shortcutStart = app.indexOf("if (isToggleResultsPaneShortcut(e, shortcuts)");
|
||||
const shortcutEnd = app.indexOf("if (isNewQueryShortcut", shortcutStart);
|
||||
const shortcut = app.slice(shortcutStart, shortcutEnd);
|
||||
|
||||
assert.ok(toggleStart >= 0);
|
||||
assert.match(toggle, /if \(props\.activeTab\.mode !== "query" \|\| !hasQueryOutput\.value\) return false/);
|
||||
assert.match(toggle, /resultsPaneOpen\.value = !resultsPaneOpen\.value/);
|
||||
assert.match(toggle, /return true/);
|
||||
assert.doesNotMatch(toggle, /closeQueryResult|clearQueryResults|closeResultSession/);
|
||||
assert.match(contentArea, /defineExpose\(\{[\s\S]*toggleResultsPane/);
|
||||
assert.ok(shortcutStart >= 0);
|
||||
assert.match(shortcut, /contentAreaRef\.value\?\.toggleResultsPane\(\)/);
|
||||
assert.match(shortcut, /e\.preventDefault\(\)/);
|
||||
assert.match(shortcut, /e\.stopPropagation\(\)/);
|
||||
});
|
||||
|
||||
test("ContentArea keeps MySQL standard explain results available in the shared toolbar", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
|
||||
assert.match(contentArea, /canShowExplainOutput = computed\([\s\S]*explainTableResult[\s\S]*explainTableError/);
|
||||
assert.match(contentArea, /:table-result="activeTab\.explainTableResult"/);
|
||||
assert.match(contentArea, /:table-error="activeTab\.explainTableError"/);
|
||||
});
|
||||
|
||||
test("DataGrid exposes persistent result toolbar slots", () => {
|
||||
const dataGrid = source(dataGridPath);
|
||||
|
||||
assert.match(dataGrid, /slots\["result-toolbar-leading"\]/);
|
||||
assert.match(dataGrid, /slots\["result-toolbar-actions"\]/);
|
||||
assert.match(dataGrid, /<slot name="result-toolbar-leading" :compact="compactDataGridToolbar"/);
|
||||
assert.match(dataGrid, /<slot v-if="hasResultToolbarActionsSlot" name="result-toolbar-actions" :compact="compactDataGridToolbar"/);
|
||||
assert.match(dataGrid, /hasResultToolbarLeadingSlot\.value \|\|[\s\S]*hasResultToolbarActionsSlot\.value/);
|
||||
});
|
||||
|
||||
test("table-data toolbar refresh keeps page size independent from SQL editor settings", () => {
|
||||
const dataGrid = source(dataGridPath);
|
||||
|
||||
// Table-data grids resolve page size through the table-open preference
|
||||
// (pageLimit ?? tableOpenPageLimit(tableOpenPageSize)) instead of the SQL editor pageSize.
|
||||
assert.match(dataGrid, /pageSizePreference = computed\(\(\) => resolveDataGridPageSizePreference\(props\.context, props\.pageSizePreference\)\)/);
|
||||
assert.match(dataGrid, /pageSize = ref\(preferredDataGridPageSize\(settingsStore\.editorSettings, pageSizePreference\.value, props\.pageLimit\)\)/);
|
||||
assert.match(dataGrid, /watch\([\s\S]*\(\) => settingsStore\.editorSettings\.pageSize,[\s\S]*if \(pageSizePreference\.value !== "results"\) return;[\s\S]*pageSize\.value = normalizeResultPageSize\(value, pageSize\.value\)/);
|
||||
// Changing the page size in the grid is temporary; only the explicit
|
||||
// set-as-default action persists it back into editor settings.
|
||||
const changePageSizeStart = dataGrid.indexOf("function changePageSize(size: number) {");
|
||||
assert.ok(changePageSizeStart >= 0);
|
||||
const changePageSizeEnd = dataGrid.indexOf("function setDefaultPageSize", changePageSizeStart);
|
||||
assert.ok(changePageSizeEnd > changePageSizeStart);
|
||||
const changePageSize = dataGrid.slice(changePageSizeStart, changePageSizeEnd);
|
||||
assert.doesNotMatch(changePageSize, /updateEditorSettings/);
|
||||
assert.match(dataGrid, /function setDefaultPageSize\(\) \{[\s\S]*?settingsStore\.updateEditorSettings\(dataGridPageSizeSettingsPatch\(pageSizePreference\.value, pageSize\.value\)\)/);
|
||||
assert.match(dataGrid, /const resetToFirstPage = hasPendingConditionInputs\(\);/);
|
||||
assert.match(dataGrid, /emit\("reload", props\.sql, searchText\.value, currentWhereInput\(\), currentOrderBy\(\), pageSize\.value, resetToFirstPage \? 0 : \(currentPage\.value - 1\) \* pageSize\.value, intent\)/);
|
||||
});
|
||||
|
||||
test("standalone result views use the same compact toolbar breakpoint", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
const dataGrid = source(dataGridPath);
|
||||
|
||||
assert.match(contentArea, /ref="standaloneResultToolbarRef"/);
|
||||
assert.match(contentArea, /isDataGridToolbarCompact\(standaloneResultToolbarWidth\.value, standaloneResultToolbarViewportWidth\.value\)/);
|
||||
assert.equal((contentArea.match(/:compact="standaloneResultToolbarCompact"/g) ?? []).length, 2);
|
||||
assert.match(dataGrid, /isDataGridToolbarCompact\(dataGridTopbarWidth\.value, dataGridViewportWidth\.value, DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH\)/);
|
||||
});
|
||||
|
||||
test("embedded result toolbar progressively compacts when editing actions overflow its available width", () => {
|
||||
const dataGrid = source(dataGridPath);
|
||||
const toolbar = source(dataGridToolbarPath);
|
||||
|
||||
assert.match(dataGrid, /dataGridTopbarMutationObserver = new MutationObserver\(updateDataGridTopbarWidth\)/);
|
||||
assert.match(dataGrid, /dataGridTopbarMutationObserver\.observe\(topbar, \{ childList: true, characterData: true, subtree: true \}\)/);
|
||||
const measurementStart = dataGrid.indexOf("function updateDataGridTopbarWidth()");
|
||||
const measurementEnd = dataGrid.indexOf("function resetDataGridTopbarOverflowCompact()", measurementStart);
|
||||
assert.ok(measurementStart >= 0 && measurementEnd > measurementStart);
|
||||
assert.match(dataGrid.slice(measurementStart, measurementEnd), /(\w+)\.scrollWidth > \1\.clientWidth \+ 1/);
|
||||
assert.match(dataGrid, /dataGridTopbarOverflowCompact\.value = true/);
|
||||
assert.match(dataGrid, /dataGridTopbarOverflowActionCount\.value = compactDataGridToolbarActionCount\.value \+ 1/);
|
||||
assert.match(dataGrid, /dataGridTopbarWidth\.value >= dataGridTopbarExpandedRequiredWidth\.value/);
|
||||
assert.match(dataGrid, /:compact-action-count="compactDataGridToolbarActionCount"/);
|
||||
assert.match(toolbar, /DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER\.filter/);
|
||||
assert.match(toolbar, /<slot name="navigation" :compact="actionIsCompact\('navigation'\)"/);
|
||||
});
|
||||
|
||||
test("embedded result toolbar remeasures after the SQL preview action is removed", () => {
|
||||
const dataGrid = source(dataGridPath);
|
||||
|
||||
assert.match(dataGrid, /if \(previousVisible && !visible\) resetDataGridTopbarOverflowCompact\(\)/);
|
||||
assert.match(dataGrid, /dataGridTopbarOverflowCompact\.value = false/);
|
||||
assert.match(dataGrid, /dataGridTopbarOverflowActionCount\.value = 0/);
|
||||
assert.match(dataGrid, /function scheduleDataGridTopbarRecheck\(\)/);
|
||||
assert.match(dataGrid, /clearDataGridTopbarRecheckTimer\(\)/);
|
||||
});
|
||||
|
||||
test("embedded result toolbar remeasures after save and rollback actions are removed", () => {
|
||||
const dataGrid = source(dataGridPath);
|
||||
|
||||
assert.match(dataGrid, /\(\) => saveToolbarState\.value\.showActions,/);
|
||||
assert.match(dataGrid, /if \(previousShowActions && !showActions\) resetDataGridTopbarOverflowCompact\(\)/);
|
||||
});
|
||||
|
||||
test("embedded and standalone result toolbars share the same fixed height", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
const dataGrid = source(dataGridPath);
|
||||
const standaloneClasses = contentArea.match(/ref="standaloneResultToolbarRef" class="([^"]+)"/)?.[1].split(/\s+/) ?? [];
|
||||
// The embedded topbar picks its height from the toolbar layout toggle; the
|
||||
// default single-row branch must match the standalone toolbar's fixed row.
|
||||
const embeddedLayoutClasses = dataGrid.match(/ref="dataGridTopbarRef"[\s\S]{0,300}?:class="([^"]+)"/)?.[1] ?? "";
|
||||
|
||||
assert.ok(standaloneClasses.includes("h-8"));
|
||||
assert.ok(embeddedLayoutClasses.includes("h-8"));
|
||||
assert.ok(standaloneClasses.includes("items-center"));
|
||||
assert.ok(embeddedLayoutClasses.includes("items-center"));
|
||||
assert.ok(!standaloneClasses.includes("h-7"));
|
||||
assert.ok(!embeddedLayoutClasses.includes("h-7"));
|
||||
assert.ok(!standaloneClasses.includes("min-h-7"));
|
||||
assert.ok(!embeddedLayoutClasses.includes("min-h-7"));
|
||||
});
|
||||
|
||||
test("embedded result toolbar cannot scroll vertically", () => {
|
||||
const dataGrid = source(dataGridPath);
|
||||
const scrollClasses = dataGrid.match(/class="data-grid-topbar-scroll ([^"]+)"/)?.[1].split(/\s+/) ?? [];
|
||||
|
||||
assert.ok(scrollClasses.includes("overflow-clip"));
|
||||
assert.ok(!scrollClasses.some((className) => className.startsWith("overflow-x-")));
|
||||
assert.ok(!scrollClasses.some((className) => className.startsWith("overflow-y-")));
|
||||
});
|
||||
|
||||
test("DataGrid marks toolbar refresh separately from current-result reloads", () => {
|
||||
const dataGrid = source(dataGridPath);
|
||||
|
||||
assert.match(dataGrid, /async function onToolbarRefresh\(\)\s*\{\s*await reloadTableData\("refresh"\);/);
|
||||
assert.match(dataGrid, /refresh: \(\) => reloadTableData\("auto-refresh"\)/);
|
||||
assert.match(dataGrid, /async function reloadTableData\(intent: DataGridReloadIntent\)[\s\S]*?emit\("reload", props\.sql,[^;]+intent\);/);
|
||||
assert.match(dataGrid, /function onToolbarRollback\(\)[\s\S]*?emit\("reload", props\.sql,[^;]+\);/);
|
||||
});
|
||||
|
||||
test("Elasticsearch JSON refresh preserves multi-result query groups", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
|
||||
assert.match(contentArea, /if \(activeElasticsearchJsonResponse\.value\) \{[\s\S]*?emit\("reload", props\.activeTab\.id, activeResultSql\.value, undefined, undefined, undefined, undefined, undefined, "refresh"\);/);
|
||||
});
|
||||
-10
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { MAX_RECENT_CONNECTION_IDS, parseRecentConnectionIds, rankRecentConnections, recordRecentConnection } from "../../apps/desktop/src/lib/connection/recentConnections.ts";
|
||||
|
||||
@@ -81,12 +80,3 @@ test("empty history preserves saved order and recency follows stable IDs after r
|
||||
["Renamed", "Connection one", "Connection three"],
|
||||
);
|
||||
});
|
||||
|
||||
test("App wires active connection changes and Quick Start opens into the MRU history", () => {
|
||||
const source = readFileSync("apps/desktop/src/App.vue", "utf8");
|
||||
|
||||
assert.ok(source.includes("rankRecentConnections(connectionStore.connections, recentConnectionIds.value)"));
|
||||
assert.ok(source.includes("if (nextIds === recentConnectionIds.value) return;"));
|
||||
assert.match(source, /watch\(\s*\(\) => connectionStore\.activeConnectionId,\s*rememberRecentConnection,?\s*\);/);
|
||||
assert.match(source, /async function openConnectionQuery\(connectionId: string\)[\s\S]*?rememberRecentConnection\(connectionId\);/);
|
||||
});
|
||||
-54
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -55,56 +54,3 @@ test("drag source allows grip button, blocks input", () => {
|
||||
assert.equal(isTextContentSearchDragSource(fake(["[data-drag-handle]", "button"])), true);
|
||||
assert.equal(isTextContentSearchDragSource(fake(["input"])), false);
|
||||
});
|
||||
|
||||
test("content find is for STRING + RedisJSON + member detail; hash toolbar search stays; no list filter", () => {
|
||||
const viewer = readFileSync(new URL("../../apps/desktop/src/components/redis/RedisValueViewer.vue", import.meta.url), "utf8");
|
||||
const jsonEditor = readFileSync(new URL("../../apps/desktop/src/components/redis/RedisJsonEditor.vue", import.meta.url), "utf8");
|
||||
const browser = readFileSync(new URL("../../apps/desktop/src/components/redis/RedisKeyBrowser.vue", import.meta.url), "utf8");
|
||||
const documentBrowser = readFileSync(new URL("../../apps/desktop/src/components/document/DocumentBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
assert.match(viewer, /valueSearchSupported/);
|
||||
// STRING, RedisJSON, or member detail.
|
||||
assert.match(viewer, /showMemberDetail\.value \|\| isStringLikeKind\.value \|\| redisKind\.value === "json"/);
|
||||
assert.match(viewer, /function openValueSearch/);
|
||||
assert.match(viewer, /function onCollectionSearch/);
|
||||
assert.match(viewer, /v-model="collectionSearchQuery"/);
|
||||
assert.match(viewer, /data-redis-member-detail/);
|
||||
assert.match(viewer, /v-if="valueSearchOpen && showMemberDetail"/);
|
||||
assert.match(viewer, /ref="redisJsonEditorRef"/);
|
||||
assert.doesNotMatch(viewer, /filterRedisCollectionByQuery/);
|
||||
assert.doesNotMatch(viewer, /valueSearchIsCollectionMode/);
|
||||
// Builtin find is a prop defaulting to true; only value-viewer owned editors disable it.
|
||||
assert.match(jsonEditor, /enableBuiltinFind\?:/);
|
||||
assert.match(jsonEditor, /enableBuiltinFind:\s*true/);
|
||||
assert.match(viewer, /:enable-builtin-find="false"/);
|
||||
// DocumentBrowser must not pass enable-builtin-find=false (keeps CM find).
|
||||
assert.doesNotMatch(documentBrowser, /enable-builtin-find/);
|
||||
assert.match(browser, /valueViewerRef\.value\?\.focusSearch\(\)/);
|
||||
});
|
||||
|
||||
test("find panel uses absolute positioning (dialog-safe, typeable)", () => {
|
||||
const bar = readFileSync(new URL("../../apps/desktop/src/components/common/TextContentSearchBar.vue", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(bar, /<Teleport/);
|
||||
assert.doesNotMatch(bar, /class="[^"]*\bfixed\b/);
|
||||
assert.match(bar, /absolute right-3 top-3/);
|
||||
assert.match(bar, /data-draggable-search-panel/);
|
||||
// Input must stop propagation so panel drag handler never captures typing clicks.
|
||||
assert.match(bar, /@pointerdown\.stop/);
|
||||
assert.match(bar, /@mousedown\.stop/);
|
||||
// Esc must not bubble to Dialog (would close member detail).
|
||||
assert.match(bar, /@keydown\.escape\.prevent\.stop/);
|
||||
});
|
||||
|
||||
test("find input must keep focus while typing (no focus:true on body scroll)", () => {
|
||||
const viewer = readFileSync(new URL("../../apps/desktop/src/components/redis/RedisValueViewer.vue", import.meta.url), "utf8");
|
||||
// Navigation/scroll must not steal focus into the value body.
|
||||
assert.match(viewer, /focus:\s*false/);
|
||||
assert.doesNotMatch(viewer, /scrollContentSearchMatchIntoView\(\{\s*focus:\s*true/);
|
||||
assert.doesNotMatch(viewer, /textarea\.focus\(/);
|
||||
});
|
||||
|
||||
test("focusSearch works for open member detail without prior pointer activation", () => {
|
||||
const viewer = readFileSync(new URL("../../apps/desktop/src/components/redis/RedisValueViewer.vue", import.meta.url), "utf8");
|
||||
assert.match(viewer, /!showMemberDetail\.value\) return false/);
|
||||
assert.match(viewer, /function focusSearch/);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import {
|
||||
DatabaseBackupConnectionQueue,
|
||||
@@ -338,15 +337,3 @@ test("retention never selects failed backup runs", () => {
|
||||
["old"],
|
||||
);
|
||||
});
|
||||
|
||||
test("scheduled backup history translates stable backend errors inline", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue", "utf8");
|
||||
|
||||
assert.match(source, /\{\{ translateBackendError\(t, run\.error\) \}\}/);
|
||||
assert.doesNotMatch(source, /\{\{ run\.error \}\}/);
|
||||
});
|
||||
|
||||
test("database exports keep the backup snapshot alive", () => {
|
||||
const exportCore = readFileSync("crates/dbx-core/src/data/database_export.rs", "utf8");
|
||||
assert.match(exportCore, /keep_manual_transaction_alive\(state, snapshot_session_id\)\.await/);
|
||||
});
|
||||
-57
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, test, vi } from "vitest";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { DEFAULT_SQL_FORMATTER_SETTINGS } from "../../apps/desktop/src/lib/sql/sqlFormatterConfig.ts";
|
||||
import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS } from "../../apps/desktop/src/lib/table/tableColumnTemplates.ts";
|
||||
@@ -238,17 +237,6 @@ test("migrates legacy execute-all settings to current once and preserves later e
|
||||
});
|
||||
});
|
||||
|
||||
test("shows the table-open page size control in the Data settings tab", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/editor/EditorSettingsDialog.vue", "utf8");
|
||||
const dataSectionStart = source.indexOf("activeSettingsTab === 'data'");
|
||||
const nextSectionStart = source.indexOf("activeSettingsTab === 'shortcuts'", dataSectionStart);
|
||||
const control = source.indexOf('id="table-open-page-size"');
|
||||
|
||||
assert.ok(dataSectionStart >= 0);
|
||||
assert.ok(nextSectionStart > dataSectionStart);
|
||||
assert.ok(control > dataSectionStart && control < nextSectionStart);
|
||||
});
|
||||
|
||||
test("defaults export batch size to 2000 rows", () => {
|
||||
assert.equal(DEFAULT_EDITOR_SETTINGS.exportBatchSize, 2000);
|
||||
assert.equal(normalizeEditorSettings({}).exportBatchSize, 2000);
|
||||
@@ -376,14 +364,6 @@ test("defaults saved SQL to its saved target and normalizes persisted target mod
|
||||
assert.equal(normalizeEditorSettings({ savedSqlOpenTargetMode: "invalid" as any }).savedSqlOpenTargetMode, "saved");
|
||||
});
|
||||
|
||||
test("shows the saved SQL target selector in Editor settings", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/editor/EditorSettingsDialog.vue", "utf8");
|
||||
|
||||
assert.match(source, /id="editor-saved-sql-open-target"/);
|
||||
assert.match(source, /<SelectItem value="saved">/);
|
||||
assert.match(source, /<SelectItem value="current">/);
|
||||
});
|
||||
|
||||
test("defaults Vim mode to off and preserves saved booleans", () => {
|
||||
assert.equal(DEFAULT_EDITOR_SETTINGS.vimModeEnabled, false);
|
||||
assert.equal(normalizeEditorSettings({}).vimModeEnabled, false);
|
||||
@@ -1024,27 +1004,6 @@ test("AI partner presets reuse a supported runtime adapter", () => {
|
||||
assert.equal(getAiProviderPreset("openai-compatible", "https://api.example.com/v1").label, "OpenAI Compatible");
|
||||
});
|
||||
|
||||
test("API AI provider settings expose and persist a default model ID", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/editor/EditorSettingsDialog.vue", "utf8");
|
||||
const modelControl = source.indexOf('<Input v-model="aiEditModel"');
|
||||
|
||||
assert.ok(modelControl >= 0);
|
||||
assert.match(source.slice(modelControl - 300, modelControl + 300), /v-if="!aiIsCliProvider"[\s\S]*t\("ai\.defaultModel"\)[\s\S]*t\('ai\.manualModelPlaceholder'\)/);
|
||||
assert.match(source, /model:\s*aiEditModel\.value/);
|
||||
});
|
||||
|
||||
test("AI connection test uses the model currently entered in the config form", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/editor/EditorSettingsDialog.vue", "utf8");
|
||||
const testConnectionStart = source.indexOf("async function aiTestConn()");
|
||||
const testConnectionEnd = source.indexOf("async function copyAiTestError()", testConnectionStart);
|
||||
const testConnection = source.slice(testConnectionStart, testConnectionEnd);
|
||||
|
||||
assert.notEqual(testConnectionStart, -1);
|
||||
assert.notEqual(testConnectionEnd, -1);
|
||||
assert.match(testConnection, /const config = currentAiEditConfig\(\);[\s\S]*aiTestConnection\(config\)/);
|
||||
assert.doesNotMatch(testConnection, /activeModel|config\.model\s*=/);
|
||||
});
|
||||
|
||||
test("normalizes legacy AI config and fills provider defaults", () => {
|
||||
const legacy = normalizeAiConfig({
|
||||
provider: "openai",
|
||||
@@ -1172,22 +1131,6 @@ test("normalizeEditorSettings keeps valid UI scales with two-decimal precision",
|
||||
assert.equal(normalizeEditorSettings({ uiScale: 1.125 }).uiScale, 1.13);
|
||||
});
|
||||
|
||||
test("shows persisted UI scales that are not available as presets", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/editor/EditorSettingsDialog.vue", "utf8");
|
||||
|
||||
assert.match(source, /<SelectValue>\{\{ Math\.round\(editUiScale \* 100\) \}\}%<\/SelectValue>/);
|
||||
});
|
||||
|
||||
test("settings page resets content scroll when switching categories", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/editor/EditorSettingsDialog.vue", "utf8");
|
||||
|
||||
assert.match(source, /const settingsContentScrollRef = ref<HTMLElement \| null>\(null\)/);
|
||||
assert.match(source, /function resetSettingsContentScroll\(\)/);
|
||||
assert.match(source, /if \(scroller\) scroller\.scrollTop = 0/);
|
||||
assert.match(source, /watch\(activeSettingsTab, async \(tab\) => \{\s+void resetSettingsContentScroll\(\);/);
|
||||
assert.match(source, /ref="settingsContentScrollRef" class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden/);
|
||||
});
|
||||
|
||||
test("defaults SQL formatter settings", () => {
|
||||
assert.deepEqual(DEFAULT_EDITOR_SETTINGS.sqlFormatter, DEFAULT_SQL_FORMATTER_SETTINGS);
|
||||
assert.deepEqual(normalizeEditorSettings({}).sqlFormatter, DEFAULT_EDITOR_SETTINGS.sqlFormatter);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { compileScript, compileTemplate, parse } from "vue/compiler-sfc";
|
||||
|
||||
const panelPath = "apps/desktop/src/components/settings/SettingsTransferPanel.vue";
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
test("settings transfer panel SFC compiles", () => {
|
||||
const { descriptor, errors } = parse(source(panelPath), { filename: panelPath });
|
||||
assert.deepEqual(errors, [], `${panelPath} should parse without SFC errors`);
|
||||
assert.ok(descriptor.scriptSetup, `${panelPath} should have a script setup block`);
|
||||
compileScript(descriptor, { id: panelPath });
|
||||
assert.ok(descriptor.template, `${panelPath} should have a template`);
|
||||
const result = compileTemplate({ id: panelPath, filename: panelPath, source: descriptor.template.content });
|
||||
assert.deepEqual(result.errors, [], `${panelPath} template should compile`);
|
||||
});
|
||||
|
||||
test("about-page settings transfer buttons follow local-container icon semantics", () => {
|
||||
const panel = source(panelPath);
|
||||
|
||||
// Treat the app as a local container: import brings data in (Download),
|
||||
// export sends data out (Upload). Same contract as SQL library / formatter.
|
||||
assert.match(panel, /@click="onImportClick"[\s\S]{0,200}<Download v-else/);
|
||||
assert.match(panel, /@click="onExportClick"[\s\S]{0,200}<Upload v-else/);
|
||||
assert.doesNotMatch(panel, /@click="onImportClick"[\s\S]{0,200}<Upload v-else/);
|
||||
assert.doesNotMatch(panel, /@click="onExportClick"[\s\S]{0,200}<Download v-else/);
|
||||
});
|
||||
-167
@@ -36,60 +36,6 @@ function functionRegion(source: string, name: string): string {
|
||||
return next ? rest.slice(0, next.index) : rest;
|
||||
}
|
||||
|
||||
test("tree-level context menu opens with the current row items atomically", () => {
|
||||
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
|
||||
const contextMenu = readFileSync("apps/desktop/src/components/ui/CustomContextMenu.vue", "utf8");
|
||||
|
||||
assert.match(connectionTree, /openContextMenu\(event, items\)/);
|
||||
assert.match(connectionTree, /sidebarContextMenuRef\.value\?\.close\(\)/);
|
||||
assert.match(connectionTree, /sidebarContextMenuTarget\.value = createSidebarActionTarget\(node\)/);
|
||||
assert.match(connectionTree, /sidebarContextMenuTarget\.value = null/);
|
||||
assert.match(connectionTree, /<CustomContextMenu ref="sidebarContextMenuRef"/);
|
||||
assert.match(contextMenu, /function onContextMenu\(event: MouseEvent, itemsOverride\?: ContextMenuItem\[\]\)/);
|
||||
assert.match(contextMenu, /const items = itemsOverride \?\?/);
|
||||
assert.match(contextMenu, /defineExpose\(\{ close, menuRef, subRef \}\)/);
|
||||
});
|
||||
|
||||
test("rare sidebar dialogs share module-level async wrappers with fallbacks", () => {
|
||||
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
|
||||
const asyncDialogs = readFileSync("apps/desktop/src/components/sidebar/sidebarAsyncDialogs.ts", "utf8");
|
||||
|
||||
assert.doesNotMatch(treeItem, /defineAsyncComponent/);
|
||||
assert.match(asyncDialogs, /loadingComponent: SidebarAsyncDialogLoading/);
|
||||
assert.match(asyncDialogs, /errorComponent: SidebarAsyncDialogError/);
|
||||
assert.match(asyncDialogs, /timeout: 15_000/);
|
||||
});
|
||||
|
||||
test("tree host owns sidebar data-open generations", () => {
|
||||
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
|
||||
|
||||
assert.doesNotMatch(treeItem, /runSidebarDataOpenImmediately/);
|
||||
assert.doesNotMatch(treeItem, /emit\("open-data"/);
|
||||
assert.match(runtimeHost, /emit\("open-data", node, true, "default", openData\)/);
|
||||
assert.match(connectionTree, /<SidebarTreeRuntimeHost/);
|
||||
assert.match(connectionTree, /function openSidebarData/);
|
||||
assert.match(connectionTree, /runSidebarDataOpenImmediately/);
|
||||
assert.match(connectionTree, /createSidebarActionTarget\(node\)/);
|
||||
});
|
||||
|
||||
test("query-tab object source opens the tab before connecting or loading", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const openObjectSourceBody = functionBody(runtimeHost, "openObjectSourceDialog");
|
||||
|
||||
// #9035:点击后立刻建出带加载态的 tab/弹窗,ensureConnected 与 getObjectSource
|
||||
// 都必须发生在已挂载的 UI 之内,而不是先 await 完再挂载(那样界面没有任何反馈)。
|
||||
assert.match(openObjectSourceBody, /queryStore\.openObjectSourceTabPending\(\{/);
|
||||
assert.match(openObjectSourceBody, /objectType: sourceTarget\.objectType/);
|
||||
assert.match(openObjectSourceBody, /signature: sourceNode\.signature/);
|
||||
assert.match(openObjectSourceBody, /emit\("open-object-source", sourceNode, initialEditing\)/);
|
||||
// 断言「调用形态」而不是词本身:这段代码里注释会提到这两个名字
|
||||
assert.doesNotMatch(openObjectSourceBody, /ensureConnected\(/);
|
||||
assert.doesNotMatch(openObjectSourceBody, /api\.getObjectSource\(/);
|
||||
assert.doesNotMatch(openObjectSourceBody, /toast\(/);
|
||||
});
|
||||
|
||||
test("object source identity and editability are enforced in queryStore", () => {
|
||||
const queryStore = readFileSync("apps/desktop/src/stores/queryStore.ts", "utf8");
|
||||
const findBody = functionRegion(queryStore, "findMatchingObjectSourceTab");
|
||||
@@ -116,67 +62,6 @@ test("object source identity and editability are enforced in queryStore", () =>
|
||||
assert.match(applyBody, /clearObjectSourceLoad\(tab\)/);
|
||||
});
|
||||
|
||||
test("table copy menu uses the shared single and multi-selection clipboard path", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const copyNameBody = functionBody(runtimeHost, "copyName");
|
||||
const copySelectedNamesBody = functionBody(runtimeHost, "copySelectedNames");
|
||||
const clipboardMenuBody = functionBody(runtimeHost, "treeTableClipboardMenuItems");
|
||||
|
||||
assert.match(clipboardMenuBody, /tableClipboardMenuState\(\s*normalizedTreeClipboardTableEntries\(\)/);
|
||||
assert.match(clipboardMenuBody, /state === "paste" \? \[pasteItem\] : \[copyItem, pasteItem\]/);
|
||||
assert.match(runtimeHost, /items\.push\(\.\.\.treeTableClipboardMenuItems\(node\)\)/);
|
||||
assert.doesNotMatch(runtimeHost, /function copyTableToClipboard\(/);
|
||||
assert.doesNotMatch(copyNameBody, /updateTreeClipboardForNodes/);
|
||||
assert.match(copySelectedNamesBody, /const selectedNodes = selectedTreeNodesInVisibleOrder\(\)/);
|
||||
assert.match(copySelectedNamesBody, /selectedNodes\.length > 1 && selectedNodes\.some\(\(node\) => node\.id === activeNode\.value\.id\) \? selectedNodes : \[activeNode\.value\]/);
|
||||
assert.match(copySelectedNamesBody, /updateTreeClipboardForNodes\(nodes\)/);
|
||||
assert.match(copySelectedNamesBody, /formatSelectedTableNamesForClipboard/);
|
||||
});
|
||||
|
||||
test("MySQL object name menus expose leaf and display-path copy choices", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const copyNameBody = functionBody(runtimeHost, "copyName");
|
||||
const copyDisplayPathBody = functionBody(runtimeHost, "copyDisplayPath");
|
||||
const copyNameMenuItemBody = functionBody(runtimeHost, "copyNameMenuItem");
|
||||
const connectionMenuBody = functionBody(runtimeHost, "buildConnectionSidebarMenu");
|
||||
const databaseMenuBody = functionBody(runtimeHost, "buildDatabaseSidebarMenu");
|
||||
const objectMenuBody = functionBody(runtimeHost, "buildObjectSidebarMenu");
|
||||
|
||||
assert.match(copyNameBody, /formatSelectedTableNamesForClipboard/);
|
||||
assert.match(copyDisplayPathBody, /copyDisplayPathForTreeNode\(node, connectionName\)/);
|
||||
assert.match(copyNameMenuItemBody, /currentDatabaseType\(\) === "mysql"/);
|
||||
assert.match(copyNameMenuItemBody, /children: \[/);
|
||||
assert.match(copyNameMenuItemBody, /t\("contextMenu\.name"\)/);
|
||||
assert.match(copyNameMenuItemBody, /t\("contextMenu\.fullPath"\)/);
|
||||
assert.match(copyNameMenuItemBody, /return \{ label: t\("contextMenu\.copyName"\), action: copyName, icon: Copy, shortcut: shortcutCopyName\.value \}/);
|
||||
assert.doesNotMatch(connectionMenuBody, /copyNameMenuItem\(\)/);
|
||||
assert.match(databaseMenuBody, /items\.push\(copyNameMenuItem\(\)\)/);
|
||||
assert.match(objectMenuBody, /items\.push\(copyNameMenuItem\(\)\)/);
|
||||
assert.match(objectMenuBody, /node\.type === "trigger" \? copyNameMenuItem\(\)/);
|
||||
assert.match(objectMenuBody, /node\.type === "sequence"[\s\S]*action: copyName/);
|
||||
});
|
||||
|
||||
test("multi-select view ddl opens a combined ddl tab instead of export structure", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const openDdlBody = functionBody(runtimeHost, "openDdl");
|
||||
|
||||
assert.doesNotMatch(openDdlBody, /exportStructure\(\)/);
|
||||
assert.match(openDdlBody, /openSidebarMultiTableDdlTab\(targets\)/);
|
||||
assert.match(runtimeHost, /openDdlForSelection/);
|
||||
});
|
||||
|
||||
test("multi-select add-to-ai only mentions tables in the active execution context", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
// The function's return type annotation contains braces, so extract the body
|
||||
// from the signature line to the next column-0 closing brace.
|
||||
const selectedAiTableTargetsBody = /function selectedAiTableTargets\([^)]*\)[^\n]*\{[\s\S]*?\n\}/.exec(runtimeHost)?.[0] ?? "";
|
||||
|
||||
assert.notEqual(selectedAiTableTargetsBody, "");
|
||||
assert.match(selectedAiTableTargetsBody, /resolveSidebarDdlTargets\(/);
|
||||
assert.match(selectedAiTableTargetsBody, /target\.type === "table"/);
|
||||
assert.doesNotMatch(selectedAiTableTargetsBody, /sidebarStructureExportTargets\(/);
|
||||
});
|
||||
|
||||
test("successful tree table paste consumes only the clipboard used to start it", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTable");
|
||||
@@ -209,13 +94,6 @@ test("tree table paste consumes the clipboard even if only the object-list refre
|
||||
assert.match(confirmPasteTableBody, /if \(refreshFailCount > 0\)[\s\S]*?pasteTableRefreshFailed/);
|
||||
});
|
||||
|
||||
test("sidebar keyboard table copy uses the same normalized schema as the context menu", () => {
|
||||
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
|
||||
const copySelectedSidebarNamesBody = functionBody(connectionTree, "copySelectedSidebarNames");
|
||||
|
||||
assert.match(copySelectedSidebarNamesBody, /schema: connectionObjectTreeNodeSchema\(store\.getConfig\(node\.connectionId!\), node\.database!, node\.schema\)/);
|
||||
});
|
||||
|
||||
test("saved SQL tree rows expose copy, paste, export, rename, and confirmed deletion through the shared runtime host", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
|
||||
@@ -238,48 +116,3 @@ test("saved SQL tree rows expose copy, paste, export, rename, and confirmed dele
|
||||
assert.match(runtimeHost, /async function confirmDeleteSavedSqlFile\(\)[\s\S]*?savedSqlStore\.deleteFile[\s\S]*?connectionStore\.removeTreeNode/);
|
||||
assert.match(functionBody(runtimeHost, "requestDeleteSelectedNode"), /saved-sql-file[\s\S]*?showDeleteSavedSqlConfirm\.value = true/);
|
||||
});
|
||||
|
||||
test("explicit locate prioritizes the saved SQL row over SQL cursor table navigation", () => {
|
||||
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
|
||||
const locateBody = functionBody(connectionTree, "locateTabInSidebar");
|
||||
|
||||
assert.match(locateBody, /const locatesSavedSql = tabTarget\?\.type === "saved-sql-file"/);
|
||||
assert.match(locateBody, /const cursorCandidate = locatesSavedSql \? null : queryCursorTableCandidate/);
|
||||
assert.match(locateBody, /locatesSavedSql && savedSqlFile\?\.connectionId && savedSqlFile\.database[\s\S]*?type: "query-context"/);
|
||||
assert.match(locateBody, /findNodePathForTarget\(target, store\.treeNodes\)/);
|
||||
});
|
||||
|
||||
test("tab context menu forwards the exact tab to centered sidebar locate without activating it", () => {
|
||||
const app = readFileSync("apps/desktop/src/App.vue", "utf8");
|
||||
const appSidebar = readFileSync("apps/desktop/src/components/layout/AppSidebar.vue", "utf8");
|
||||
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
|
||||
const appLocateBody = functionBody(app, "locateTabInSidebar");
|
||||
const sidebarLocateBody = functionBody(appSidebar, "locateTabInSidebar");
|
||||
const activeLocateBody = functionBody(connectionTree, "locateActiveTabInSidebar");
|
||||
const locateBody = functionBody(connectionTree, "locateTabInSidebar");
|
||||
|
||||
assert.match(app, /@locate-tab="locateTabInSidebar"/);
|
||||
assert.match(appLocateBody, /setSidebarOpen\(true\)/);
|
||||
assert.match(appLocateBody, /await nextTick\(\)/);
|
||||
assert.match(appLocateBody, /await appSidebarRef\.value\?\.locateTabInSidebar\(tab\)/);
|
||||
assert.doesNotMatch(appLocateBody, /activateQueryTab|activeTabId/);
|
||||
assert.match(sidebarLocateBody, /return connectionTreeRef\.value\?\.locateTabInSidebar\(tab\)/);
|
||||
assert.match(appSidebar, /defineExpose\(\{ focusSearch, locateTabInSidebar \}\)/);
|
||||
assert.match(activeLocateBody, /await locateTabInSidebar\(activeTab\.value, "smart"\)/);
|
||||
assert.match(locateBody, /await scrollToSidebarNode\(match\.id, \{ align \}\)/);
|
||||
assert.match(connectionTree, /defineExpose\(\{ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSidebar \}\)/);
|
||||
assert.match(connectionTree, /@request-connection-rename="startRenamingConnectionNode"/);
|
||||
});
|
||||
|
||||
test("batch table paste refreshes each object list after all tables are processed", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTable");
|
||||
const pasteLoopIndex = confirmPasteTableBody.indexOf("for (const entry of entries)");
|
||||
const refreshLoopIndex = confirmPasteTableBody.indexOf("for (const refreshTarget of refreshTargets.values())");
|
||||
|
||||
assert.notEqual(pasteLoopIndex, -1);
|
||||
assert.notEqual(refreshLoopIndex, -1);
|
||||
assert.ok(refreshLoopIndex > pasteLoopIndex, "object-list refresh must run after the table paste loop");
|
||||
assert.doesNotMatch(confirmPasteTableBody.slice(pasteLoopIndex, refreshLoopIndex), /refreshObjectListTreeNode/);
|
||||
assert.match(confirmPasteTableBody.slice(refreshLoopIndex), /refreshObjectListTreeNode\(refreshTarget\.connectionId, refreshTarget\.database, refreshTarget\.schema\)/);
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
|
||||
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const dataOpenRuntime = readFileSync("apps/desktop/src/composables/useSidebarDataOpenRuntime.ts", "utf8");
|
||||
|
||||
function occurrences(source: string, pattern: RegExp): number {
|
||||
return source.match(pattern)?.length ?? 0;
|
||||
}
|
||||
|
||||
test("sidebar rows stay within the hot-path source budget", () => {
|
||||
assert.ok(treeItem.split("\n").length <= 2_200, "TreeItem must stay below 2,200 lines");
|
||||
assert.ok(occurrences(treeItem, /^(?:async )?function /gm) <= 90, "TreeItem must stay below 90 top-level functions");
|
||||
// +1 for the plugin-platform connection visual (provider SVG on plugin rows)
|
||||
// and +1 for the disabled-trigger indicator.
|
||||
assert.ok(occurrences(treeItem, /^const [A-Za-z0-9_]+ = computed\(/gm) <= 30, "TreeItem must stay below 30 computed values");
|
||||
assert.doesNotMatch(treeItem, /ContextMenuItem|emit\("open-data"|getTreeItemDialogController|executeWithProductionSqlGuard/);
|
||||
});
|
||||
|
||||
test("one tree-level runtime serves every row renderer", () => {
|
||||
assert.equal(occurrences(connectionTree, /<SidebarTreeRuntimeHost\b/g), 1);
|
||||
assert.equal(occurrences(connectionTree, /provide\(sidebarTreeRuntimeKey, sidebarTreeRuntime\)/g), 1);
|
||||
assert.match(connectionTree, /sidebarTreeRuntime\.buildContextMenu\(node\)/);
|
||||
assert.match(runtimeHost, /<template \/>/);
|
||||
assert.match(runtimeHost, /function bindMenuTarget\(/);
|
||||
assert.match(runtimeHost, /function activateActionTarget\(/);
|
||||
assert.match(runtimeHost, /useSidebarDataOpenRuntime\(\)/);
|
||||
assert.doesNotMatch(runtimeHost, /async function openData\(/);
|
||||
assert.match(dataOpenRuntime, /async function openData\(/);
|
||||
assert.match(dataOpenRuntime, /findExistingDataTabCandidate/);
|
||||
assert.match(dataOpenRuntime, /loadTableMetadata/);
|
||||
assert.match(dataOpenRuntime, /canApplyDataTabMetadata/);
|
||||
});
|
||||
|
||||
test("connection detail tooltips expose every known visible-filter count", () => {
|
||||
assert.match(treeItem, /connectionStore\.getSidebarVisibleFilterSummary\(node\.connectionId\)/);
|
||||
assert.match(treeItem, /visibleFilterSummary\?\.selected != null && visibleFilterSummary\.total != null/);
|
||||
assert.doesNotMatch(treeItem, /visibleFilterSummary\?\.isActive/);
|
||||
assert.match(treeItem, /visibleSchemas\.detailLabel/);
|
||||
assert.match(treeItem, /visibleDatabases\.detailLabel/);
|
||||
assert.match(treeItem, /treeRuntime\.openPrimaryVisibleFilter\(node\)/);
|
||||
assert.match(runtimeHost, /function openPrimaryVisibleFilter\(node: TreeNode\)/);
|
||||
assert.match(runtimeHost, /openVisibleDatabasesDialog\(\)/);
|
||||
assert.doesNotMatch(treeItem, /SidebarVisibleFilterControl/);
|
||||
});
|
||||
|
||||
test("the persistent runtime releases detached tree nodes", () => {
|
||||
const actionTarget = readFileSync("apps/desktop/src/lib/sidebar/sidebarActionTarget.ts", "utf8");
|
||||
const connectionMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts", "utf8");
|
||||
const tableMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarTableMutationRuntime.ts", "utf8");
|
||||
|
||||
assert.match(actionTarget, /function releaseRemovedSidebarActionTarget/);
|
||||
assert.match(actionTarget, /children: undefined, hiddenChildren: undefined/);
|
||||
assert.match(runtimeHost, /\(\) => connectionStore\.treeNodes/);
|
||||
assert.match(runtimeHost, /findSidebarActionTarget\(nodes, createSidebarActionTarget\(activeNode\.value\)\)/);
|
||||
assert.match(connectionMutationRuntime, /releaseActiveNodeReference\(targets\.map\(\(target\) => target\.id\)\)/);
|
||||
assert.match(tableMutationRuntime, /releaseActiveNodeReference\(\[node\.id\]\)/);
|
||||
});
|
||||
@@ -1,49 +1,8 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { syncSidebarTreeNodeExpansion } from "../../apps/desktop/src/lib/sidebar/sidebarTreeExpansion.ts";
|
||||
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
|
||||
const sidebarSearchTree = readFileSync("apps/desktop/src/lib/sidebar/sidebarSearchTree.ts", "utf8");
|
||||
const connectionStore = readFileSync("apps/desktop/src/stores/connectionStore.ts", "utf8");
|
||||
|
||||
test("sidebar rows retain database-specific node affordances", () => {
|
||||
for (const nodeType of ["connection", "database", "schema", "table", "column", "mongo-db", "mongo-collection", "redis-db", "nacos-namespace", "mq-tenant"]) {
|
||||
const sources = `${treeItem}\n${runtimeHost}`;
|
||||
assert.ok(sources.includes(`node.type === "${nodeType}"`) || sources.includes(`node.type === '${nodeType}'`), nodeType);
|
||||
}
|
||||
assert.match(treeItem, /@dblclick="onDoubleClick"/);
|
||||
assert.match(treeItem, /@keydown="onKeydown"/);
|
||||
assert.match(treeItem, /@mousedown="onRowMouseDown"/);
|
||||
assert.match(treeItem, /@contextmenu="onTreeItemContextMenu"/);
|
||||
});
|
||||
|
||||
test("plain tree sticky headers position the full row wrapper without a divider", () => {
|
||||
assert.match(treeItem, /<div v-else :class="\{ 'sidebar-tree-item--sticky': stickyHeader \}" @contextmenu="onTreeItemContextMenu">/);
|
||||
assert.doesNotMatch(treeItem, /'tree-item-highlight': highlighted,\s*'sidebar-tree-item--sticky': stickyHeader/);
|
||||
assert.match(treeItem, /\.sidebar-tree-item--sticky\s*\{[\s\S]*?position:\s*sticky;/);
|
||||
assert.match(treeItem, /\.sidebar-tree-item--sticky\s*\{[\s\S]*?background-color:\s*var\(--sidebar\);/);
|
||||
assert.doesNotMatch(treeItem, /\.sidebar-tree-item--sticky\s*\{[^}]*border-bottom:/);
|
||||
assert.doesNotMatch(connectionTree, /sticky-database-header[^\n]*border-b/);
|
||||
assert.match(connectionTree, /\.sticky-database-header\s*\{[\s\S]*?background-color:\s*var\(--sidebar\);/);
|
||||
assert.match(connectionTree, /v-for="\(item, index\) in flatNodes"/);
|
||||
assert.match(connectionTree, /:sticky-header="isPlainStickyContainerNode\(index\)"/);
|
||||
assert.match(connectionTree, /return flatTreeIndex\.value\.stickyContainerIndexByIndex\[index\] === index;/);
|
||||
});
|
||||
|
||||
test("complex tree changes retain the full rebuild fallback", () => {
|
||||
assert.match(connectionTree, /const filteredNodes = computed/);
|
||||
assert.match(connectionTree, /filterSidebarTree\(/);
|
||||
assert.match(connectionTree, /const flatNodes = computed<FlatTreeNode\[]>/);
|
||||
assert.match(connectionTree, /flattenTree\(filteredNodes\.value\)/);
|
||||
assert.match(connectionTree, /watch\(flatNodes,/);
|
||||
assert.doesNotMatch(connectionTree, /treeScrollerRef\.value\?\.(?:forceUpdate|updateVisibleItems)/);
|
||||
assert.match(connectionTree, /@node-toggled="onNodeToggled"/);
|
||||
});
|
||||
|
||||
test("tree toggles synchronize filtered node clones with the live sidebar tree", () => {
|
||||
const expandedConnection: TreeNode = {
|
||||
id: "connection-1",
|
||||
@@ -86,25 +45,3 @@ test("async tree expansion does not restore a stale rendered clone state", () =>
|
||||
assert.equal(syncSidebarTreeNodeExpansion([liveDatabase], staleRenderedClone, true), false);
|
||||
assert.equal(liveDatabase.isExpanded, true);
|
||||
});
|
||||
|
||||
test("tree filters retain a temporary expansion state", () => {
|
||||
assert.match(sidebarSearchTree, /return \{ \.\.\.node, children: matchingChildren \};/);
|
||||
assert.doesNotMatch(sidebarSearchTree, /children: matchingChildren,\s*isExpanded:\s*true/);
|
||||
assert.match(connectionTree, /function onSearchToggle\(node: TreeNode\) \{\s*if \(!isTreeSearchFiltering\.value \|\| !node\.children\) return;/);
|
||||
// The search guard must stay the first thing in onNodeToggled (filter toggles
|
||||
// must never sync back to the live tree); side-effect-free diagnostics may be
|
||||
// interleaved before the sync call, so match the guard and the required sync
|
||||
// separately instead of pinning the exact body.
|
||||
assert.match(connectionTree, /function onNodeToggled\(node: TreeNode, expanded: boolean\) \{\s*if \(isTreeSearchFiltering\.value\) return;[\s\S]*?syncSidebarTreeNodeExpansion\(store\.treeNodes, node, expanded\)/);
|
||||
assert.match(runtimeHost, /shouldRunTreeNodeRowAction\(action, clickDetail, isGroupLabel\(node\) \|\| isRepeatableNavigationTreeNode\(node\.type\)\)/);
|
||||
});
|
||||
|
||||
test("tree rebuilds keep a context menu only while its target row remains visible", () => {
|
||||
assert.match(connectionTree, /nodes\.find\(\(\{ node \}\) => matchesSidebarActionTarget\(node, contextMenuTarget\)\)\?\.node/);
|
||||
assert.match(connectionTree, /if \(!visibleContextMenuTarget \|\| visibleContextMenuTarget\.valid === false\)/);
|
||||
});
|
||||
|
||||
test("programmable object groups use the shared metadata loader", () => {
|
||||
assert.match(runtimeHost, /const databaseObjectGroup = !!objectTypesForGroupNode\(node\.type\)/);
|
||||
assert.match(connectionStore, /else if \(objectTypesForGroupNode\(node\.type\)\) \{\s*await loadObjectGroupChildren\(node, options\);/);
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { compileScript, parse } from "vue/compiler-sfc";
|
||||
|
||||
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
|
||||
|
||||
test("sidebar row template components have script bindings", () => {
|
||||
const descriptor = parse(treeItem).descriptor;
|
||||
const bindings = compileScript(descriptor, { id: "sidebar-tree-item" }).bindings;
|
||||
const template = descriptor.template;
|
||||
assert.ok(template);
|
||||
|
||||
const componentTags = new Set(Array.from(template.content.matchAll(/<([A-Z][A-Za-z0-9]*)\b/g), (match) => match[1]));
|
||||
|
||||
for (const tag of componentTags) assert.ok(bindings[tag], `TreeItem template component ${tag} must be imported`);
|
||||
});
|
||||
|
||||
test("recycled sidebar rows re-register paste handlers by node id", () => {
|
||||
assert.ok(treeItem.indexOf("const sidebarTreeContext = inject") < treeItem.indexOf("const stopPasteHandlerRegistration = watch"));
|
||||
assert.match(treeItem, /watch\(\s*\(\) => props\.node\.id,/);
|
||||
assert.match(treeItem, /registerPasteHandler\?\.\(nodeId, \(\) => treeRuntime\.requestPaste\(props\.node\)\)/);
|
||||
assert.match(treeItem, /if \(unregister\) onCleanup\(unregister\)/);
|
||||
});
|
||||
|
||||
test("connection group rename requests are owned by the rendered row", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const connectionMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts", "utf8");
|
||||
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
|
||||
|
||||
assert.match(treeItem, /const renameInputRef = ref<HTMLInputElement>\(\)/);
|
||||
assert.match(treeItem, /watch\(\s*\(\) => props\.pendingRename,[\s\S]*?startRenameGroup\(\)/);
|
||||
assert.match(treeItem, /focusSidebarRenameInput\(\(\) => \(isRenamingGroup\.value \? renameInputRef\.value : undefined\)\)/);
|
||||
assert.match(runtimeHost, /requestGroupRename: \(groupId\) => emit\("request-group-rename", groupId\)/);
|
||||
assert.match(connectionMutationRuntime, /options\.requestGroupRename\(activeNode\.value\.id\)/);
|
||||
assert.doesNotMatch(runtimeHost, /const isRenamingGroup = ref\(/);
|
||||
assert.match(connectionTree, /@request-group-rename="startRenamingCreatedGroup"/);
|
||||
});
|
||||
|
||||
test("sidebar row unmount clears observers, handlers and drag state", () => {
|
||||
assert.match(treeItem, /function handleMouseLeave\(\)[\s\S]*?labelResizeObserver\?\.disconnect\(\)/);
|
||||
assert.match(treeItem, /function finishTableReferenceDrag\(\)[\s\S]*?document\.removeEventListener\("mousemove"/);
|
||||
assert.match(treeItem, /onBeforeUnmount\(\(\) => \{[\s\S]*?stopPasteHandlerRegistration\(\)[\s\S]*?handleMouseLeave\(\)[\s\S]*?finishTableReferenceDrag\(\)/);
|
||||
assert.match(treeItem, /watch\([\s\S]*?\(\) => props\.node,[\s\S]*?finishTableReferenceDrag\(\)/);
|
||||
});
|
||||
|
||||
test("sidebar rows do not own dialog templates or eager dialog state", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
assert.doesNotMatch(treeItem, /<Dialog(?:\s|>)/);
|
||||
assert.doesNotMatch(treeItem, /<DangerConfirmDialog/);
|
||||
assert.doesNotMatch(treeItem, /const show[A-Z][A-Za-z]+(?:Dialog|Confirm) = ref\(/);
|
||||
assert.doesNotMatch(treeItem, /function getTreeItemDialogController\(\)/);
|
||||
assert.match(treeItem, /inject\(sidebarTreeRuntimeKey\)/);
|
||||
assert.match(runtimeHost, /function getTreeItemDialogController\(\)/);
|
||||
assert.match(runtimeHost, /if \(treeItemDialogController\) return treeItemDialogController/);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { compileScript, compileTemplate, parse } from "vue/compiler-sfc";
|
||||
|
||||
const dialogPath = "apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue";
|
||||
const dialogSource = readFileSync(dialogPath, "utf8");
|
||||
|
||||
test("SQL file execution dialog SFC compiles", () => {
|
||||
const { descriptor, errors } = parse(dialogSource, { filename: dialogPath });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(descriptor.scriptSetup);
|
||||
compileScript(descriptor, { id: dialogPath });
|
||||
assert.ok(descriptor.template);
|
||||
const result = compileTemplate({ id: dialogPath, filename: dialogPath, source: descriptor.template.content });
|
||||
assert.deepEqual(result.errors, []);
|
||||
});
|
||||
|
||||
test("SQL file execution dialog keeps actions visible within narrow viewports", () => {
|
||||
assert.match(dialogSource, /DialogScrollContent class="[^"]*max-h-\[calc\(var\(--dbx-viewport-height\)-6rem\)\][^"]*flex-col[^"]*overflow-hidden/);
|
||||
assert.match(dialogSource, /<DialogHeader class="shrink-0">/);
|
||||
assert.match(dialogSource, /class="grid min-h-0 min-w-0 flex-1 gap-4 overflow-y-auto py-3"/);
|
||||
assert.match(dialogSource, /class="grid grid-cols-1 gap-3 sm:grid-cols-2"/);
|
||||
assert.match(dialogSource, /<DialogFooter class="shrink-0">/);
|
||||
});
|
||||
|
||||
test("SQL file execution summary reserves space for aggregate columns", () => {
|
||||
assert.match(dialogSource, /v-if="!running && previews\.length > 1 && perFileResults\.length > 0"/);
|
||||
assert.doesNotMatch(dialogSource, /terminalStatus === 'done' && previews\.length > 1 && perFileResults\.length > 0/);
|
||||
assert.match(dialogSource, /<table class="w-full table-fixed">/);
|
||||
assert.match(dialogSource, /<colgroup>[\s\S]*<col \/>[\s\S]*<col class="w-\[4\.5rem\]" \/>[\s\S]*<col class="w-\[5\.5rem\]" \/>[\s\S]*<\/colgroup>/);
|
||||
assert.match(dialogSource, /<td class="px-2\.5 py-1\.5 truncate" :title="item\.fileName">/);
|
||||
assert.match(dialogSource, /<thead class="sticky top-0 z-10 border-b border-border bg-muted text-foreground shadow-/);
|
||||
assert.match(dialogSource, /<tfoot class="sticky bottom-0 z-10 border-t-2 border-primary\/35 bg-muted font-semibold text-foreground/);
|
||||
assert.match(dialogSource, /<th scope="row" class="px-2\.5 py-2 text-left">\{\{ t\("sqlFile\.totalFiles"/);
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
const source = readFileSync("apps/desktop/src/components/layout/SqlLibraryPanel.vue", "utf8");
|
||||
|
||||
test("SQL library imports use the charset-aware external SQL reader", () => {
|
||||
const start = source.indexOf("async function importDirectoryIntoLibrary");
|
||||
const end = source.indexOf("async function chooseSyncDirectory", start);
|
||||
const handler = start >= 0 && end > start ? source.slice(start, end) : "";
|
||||
|
||||
assert.match(handler, /await api\.readExternalSqlFile\(path, externalSqlEditorMaxBytes\(settingsStore\.editorSettings\.externalSqlEditorMaxMb\)\)/);
|
||||
assert.doesNotMatch(handler, /readTextFile/);
|
||||
});
|
||||
|
||||
test("SQL library directory imports use the native pruned folder scanner", () => {
|
||||
const start = source.indexOf("async function collectSqlFilesRecursively");
|
||||
const end = source.indexOf("async function importDirectoryIntoLibrary", start);
|
||||
const scanner = start >= 0 && end > start ? source.slice(start, end) : "";
|
||||
|
||||
assert.match(scanner, /await api\.listSqlFilesInFolder\(dir\)/);
|
||||
assert.doesNotMatch(scanner, /@tauri-apps\/plugin-fs|readDir/);
|
||||
});
|
||||
|
||||
test("SQL library directory imports preserve nested folder paths", () => {
|
||||
const start = source.indexOf("async function importDirectoryIntoLibrary");
|
||||
const end = source.indexOf("async function chooseSyncDirectory", start);
|
||||
const handler = start >= 0 && end > start ? source.slice(start, end) : "";
|
||||
|
||||
assert.match(handler, /resolveImportedFolder\(folderConnectionId, targetFolder\?\.id, file\.folderNames, folderCache\)/);
|
||||
assert.match(handler, /uniqueImportedName\(file\.name, takenNames\)/);
|
||||
assert.doesNotMatch(handler, /relativeImportName/);
|
||||
});
|
||||
|
||||
test("SQL library file menu can open a file with the current tab target", () => {
|
||||
assert.match(source, /label: t\("sqlLibrary\.openInCurrentDatabase"\)/);
|
||||
assert.match(source, /action: \(\) => openFile\(target, "current"\)/);
|
||||
assert.match(source, /disabled: !hasCurrentSavedSqlExecutionTarget\.value/);
|
||||
});
|
||||
-11
@@ -1,6 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "vitest";
|
||||
import { applySshConfigHostAliasPrefill } from "../../apps/desktop/src/lib/connection/sshConfigHosts.ts";
|
||||
import type { SshTunnelConfig } from "../../apps/desktop/src/types/database.ts";
|
||||
@@ -62,12 +60,3 @@ test("leaves the form unchanged when no config alias matches", () => {
|
||||
|
||||
assert.deepEqual(target, before);
|
||||
});
|
||||
|
||||
test("loads and exposes SSH config aliases in tunnel profile maintenance", () => {
|
||||
const component = readFileSync(path.resolve("apps/desktop/src/components/connection/TunnelProfileManager.vue"), "utf8");
|
||||
|
||||
assert.match(component, /api\.listSshConfigHosts\(\)/);
|
||||
assert.match(component, /list="tunnel-profile-ssh-config-host-aliases"/);
|
||||
assert.match(component, /<datalist id="tunnel-profile-ssh-config-host-aliases">/);
|
||||
assert.match(component, /@update:model-value="updateSelectedSshHost"/);
|
||||
});
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
function functionBody(source: string, name: string): string {
|
||||
const signature = `async function ${name}(`;
|
||||
const signatureIndex = source.indexOf(signature);
|
||||
assert.notEqual(signatureIndex, -1, `Could not find function ${name}`);
|
||||
const bodyStart = source.indexOf("{", signatureIndex);
|
||||
assert.notEqual(bodyStart, -1, `Could not find body for ${name}`);
|
||||
|
||||
let depth = 0;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
if (source[index] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart + 1, index);
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not parse body for ${name}`);
|
||||
}
|
||||
|
||||
test("sync downloads refresh shared tunnel profiles", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/editor/EditorSettingsDialog.vue", "utf8");
|
||||
|
||||
for (const functionName of ["downloadSnippetSnapshot", "downloadWebDavSnapshot"]) {
|
||||
const body = functionBody(source, functionName);
|
||||
assert.match(body, /await tunnelProfileStore\.refresh\(\)/, `${functionName} should refresh downloaded tunnel profiles`);
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { compileTemplate, parse } from "vue/compiler-sfc";
|
||||
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
|
||||
|
||||
const tableInfoSurfaces = ["apps/desktop/src/components/objects/ObjectBrowser.vue", "apps/desktop/src/components/grid/DataGridTableInfoPanels.vue"];
|
||||
|
||||
test("table column defaults preserve raw database expressions", () => {
|
||||
const values = [null, "''", "0", "CURRENT_TIMESTAMP", "((1))", "('prefix (internal)')", "x".repeat(512)] as const;
|
||||
|
||||
@@ -13,19 +9,3 @@ test("table column defaults preserve raw database expressions", () => {
|
||||
assert.equal(tableColumnDefaultDisplayValue(undefined), "—");
|
||||
assert.equal(tableColumnDefaultDisplayValue(""), "");
|
||||
});
|
||||
|
||||
test("both table information surfaces expose the same default-value column", () => {
|
||||
for (const path of tableInfoSurfaces) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
const { descriptor, errors } = parse(source, { filename: path });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(descriptor.template);
|
||||
const result = compileTemplate({ id: path, filename: path, source: descriptor.template.content });
|
||||
assert.deepEqual(result.errors, []);
|
||||
|
||||
assert.match(descriptor.template.content, /t\("structureEditor\.defaultValue"\)/);
|
||||
assert.match(descriptor.template.content, /data-table-info-column-default/);
|
||||
assert.match(descriptor.template.content, /:title="column\.column_default \?\? undefined"/);
|
||||
assert.match(descriptor.template.content, /tableColumnDefaultDisplayValue\(column\.column_default\)/);
|
||||
}
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
// The table-properties drawer exists twice (DataGrid and ObjectBrowser), each
|
||||
// with its own tab list, plus a shared body component. A partitions tab must be
|
||||
// wired into all of them together, otherwise the drawer silently loses the tab
|
||||
// on one surface.
|
||||
const drawerSurfaces = [
|
||||
"apps/desktop/src/components/grid/DataGrid.vue",
|
||||
"apps/desktop/src/components/objects/ObjectBrowser.vue",
|
||||
];
|
||||
|
||||
test("both table-properties drawers expose a capability-gated partitions tab", () => {
|
||||
for (const path of drawerSurfaces) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
assert.match(source, /id:\s*"partitions"/, `${path} must add a partitions tab`);
|
||||
assert.match(
|
||||
source,
|
||||
/tableMetadataCapabilities\.value\.partitions/,
|
||||
`${path} must gate the partitions tab on the capability`,
|
||||
);
|
||||
// A plain table must not show the tab at all, so both drawers probe the
|
||||
// cheap partition status and gate on the result.
|
||||
assert.match(source, /api\.getTablePartitionStatus/, `${path} must probe the partition status`);
|
||||
assert.match(source, /isPartitionedTable|tableIsPartitioned/, `${path} must gate on the probed status`);
|
||||
assert.match(source, /fetchPartitions|fetchTablePartitions/, `${path} must load partition metadata`);
|
||||
}
|
||||
});
|
||||
|
||||
test("both drawers probe the partition status on demand, not only on table change", () => {
|
||||
// Regression: probing only in the table-change watcher meant opening the
|
||||
// drawer for an already-selected table never resolved the status, so the
|
||||
// Partitions tab was missing on every partitioned table.
|
||||
const expectations: Array<[string, RegExp]> = [
|
||||
["apps/desktop/src/components/grid/DataGrid.vue", /if \(!partitionStatusResolved\.value\) await probeTablePartitionStatus\(\)/],
|
||||
[
|
||||
"apps/desktop/src/components/objects/ObjectBrowser.vue",
|
||||
/if \(!tablePartitionStatusResolved\.value\) await probeTablePartitionStatus\(\)/,
|
||||
],
|
||||
];
|
||||
for (const [path, pattern] of expectations) {
|
||||
assert.match(readFileSync(path, "utf8"), pattern, `${path} must probe on demand in selectTableInfoTab`);
|
||||
}
|
||||
});
|
||||
|
||||
test("both drawers render the shared partitions panel", () => {
|
||||
const panels = readFileSync("apps/desktop/src/components/grid/DataGridTableInfoPanels.vue", "utf8");
|
||||
assert.match(panels, /TablePartitionsPanel/);
|
||||
|
||||
const objectBrowser = readFileSync("apps/desktop/src/components/objects/ObjectBrowser.vue", "utf8");
|
||||
assert.match(objectBrowser, /TablePartitionsPanel/);
|
||||
});
|
||||
|
||||
test("the partitions tab is a first-class persisted table-info tab", () => {
|
||||
const settings = readFileSync("apps/desktop/src/stores/settingsStore.ts", "utf8");
|
||||
assert.match(settings, /TABLE_INFO_TABS = new Set<TableInfoTab>\(\[[^\]]*"partitions"/);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import {
|
||||
applyManticoreDdlColumnExtras,
|
||||
@@ -661,31 +660,6 @@ test("falls back to DDL when no editable metadata tab is available", () => {
|
||||
assert.equal(firstStructureMetadataTab(ddlOnlyCapabilities, false), "ddl");
|
||||
});
|
||||
|
||||
test("preserves a restored structure draft tab without an explicit initial tab", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/structure/TableStructureEditor.vue", "utf8");
|
||||
const restoredDraftBlock = source.match(/if \(props\.draft\?\.initialized\) \{[\s\S]*?\n \} else if/);
|
||||
|
||||
assert.ok(restoredDraftBlock);
|
||||
assert.match(restoredDraftBlock[0], /restoreDraft\(props\.draft\);[\s\S]*applyPendingInitialStructureTab\(\);[\s\S]*applyInitialStructureTarget\(\);/);
|
||||
});
|
||||
|
||||
test("renders editable structure tables with flat cell controls", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/structure/TableStructureEditor.vue", "utf8");
|
||||
|
||||
assert.match(source, /<CustomContextMenu :items="activeColumnContextMenuItems"[\s\S]*<RecycleScroller[\s\S]*item-class="structure-column-virtual-row"[\s\S]*list-class="structure-column-virtual-list"/);
|
||||
assert.match(source, /:item-size="columnVirtualItemSize"/);
|
||||
assert.match(source, /const useColumnVirtualFlowMode = isTauriRuntime\(\) && isMacOS\(\);/);
|
||||
assert.match(source, /:flow-mode="useColumnVirtualFlowMode"/);
|
||||
assert.doesNotMatch(source, /:disable-transform=/);
|
||||
assert.match(source, /<template #default="\{ item: \{ column, index \}, active \}">[\s\S]*<table class="structure-column-virtual-row-table/);
|
||||
assert.doesNotMatch(source, /<CustomContextMenu :key="column\.id"/);
|
||||
assert.match(source, /<tbody class="structure-edit-grid">/);
|
||||
assert.match(source, /const structureControlClass = "structure-grid-control /);
|
||||
assert.match(source, /\.structure-edit-grid :deep\(\.structure-grid-control\) \{[\s\S]*?border-radius: 0;/);
|
||||
assert.match(source, /\.structure-edit-grid > tbody > tr > td:focus-within,[\s\S]*?\.structure-edit-grid > tr > td:focus-within \{/);
|
||||
assert.match(source, /\.structure-column-virtual-scroller\.is-scrolling :deep\(\.structure-column-virtual-row \*\) \{[\s\S]*?transition: none !important;/);
|
||||
});
|
||||
|
||||
test("supports DDL tab in edit mode", () => {
|
||||
assert.equal(isStructureMetadataTabSupported("ddl", fullCapabilities, false), true);
|
||||
});
|
||||
|
||||
-9
@@ -1,11 +1,8 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { createFlatTreeIndex, SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree } from "../../apps/desktop/src/composables/useFlatTree.ts";
|
||||
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
const connectionTreeSource = readFileSync(new URL("../../apps/desktop/src/components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
|
||||
|
||||
test("flattenTree preserves depth and node type for virtualized sidebar rows", () => {
|
||||
const nodes: TreeNode[] = [
|
||||
{
|
||||
@@ -127,9 +124,3 @@ test("sidebar virtual tree keeps enough buffered rows for fast scrolling", () =>
|
||||
test("sidebar virtual tree prerenders enough rows for the first frame", () => {
|
||||
assert.ok(SIDEBAR_TREE_PRERENDER_COUNT >= 40);
|
||||
});
|
||||
|
||||
test("sidebar virtual tree recycles rows by render identity", () => {
|
||||
assert.match(connectionTreeSource, /key-field="renderKey"/);
|
||||
assert.match(connectionTreeSource, /type-field="poolType"/);
|
||||
assert.match(connectionTreeSource, /\bflow-mode\b/);
|
||||
});
|
||||
Reference in new issue
Block a user