Files
MyGoNavi/frontend/src/components/QueryEditor.results-and-drop.test.tsx
2026-07-30 21:28:23 +08:00

4884 lines
176 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { readFileSync } from 'node:fs';
import { act, create as createRenderer, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readV2ThemeCss } from '../test/readV2ThemeCss';
import { setCurrentLanguage } from '../i18n';
import type { SavedQuery, TabData } from '../types';
import { clearQueryEditorResultSession } from '../utils/queryEditorResultSessionCache';
import { formatSqlExecutionError } from '../utils/sqlErrorSemantics';
import { clearQueryTabDraft, clearSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
import {
CLOSE_ACTIVE_RESULT_TAB_EVENT,
type CloseActiveResultShortcutRequest,
} from '../utils/closeTabShortcut';
import { normalizeQueryResultMessages } from './queryEditor/QueryEditorHelpers';
import QueryEditor, {
collectQueryEditorObjectDecorationCandidates,
filterQueryEditorResultSetsForBulkClose,
resolveQueryEditorNavigationDecorations,
resolveQueryEditorNavigationTarget,
} from './QueryEditor';
import QueryEditorResultsPanel, {
QUERY_EDITOR_SQL_LOG_TAB_KEY,
resolveEffectiveActiveResultKey,
shouldActivateResultTabDetachPointer,
} from './QueryEditorResultsPanel';
const mountedRenderers = new Set<ReactTestRenderer>();
const create = (...args: Parameters<typeof createRenderer>): ReactTestRenderer => {
const renderer = createRenderer(...args);
mountedRenderers.add(renderer);
const unmount = renderer.unmount.bind(renderer);
renderer.unmount = () => {
mountedRenderers.delete(renderer);
unmount();
};
return renderer;
};
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
sqlLogs: [] as Array<{
id: string;
timestamp: number;
sql: string;
status: 'success' | 'error';
duration: number;
}>,
clearSqlLogs: vi.fn(),
addSqlLog: vi.fn(),
addTab: vi.fn(),
setActiveContext: vi.fn(),
updateQueryTabDraft: vi.fn(),
savedQueries: [] as SavedQuery[],
saveQuery: vi.fn(),
theme: 'light',
fontSize: 14,
languagePreference: 'zh-CN' as 'zh-CN' | 'en-US',
appearance: {
uiVersion: 'legacy' as 'legacy' | 'v2',
customMonoFontFamily: null as string | null,
dataTableFontSize: null as number | null,
dataTableFontSizeFollowGlobal: true,
sqlEditorFontSize: null as number | null,
sqlEditorFontSizeFollowGlobal: true,
},
sqlFormatOptions: { keywordCase: 'upper' as const },
setSqlFormatOptions: vi.fn(),
queryOptions: {
maxRows: 5000,
showColumnComment: true,
showColumnType: true,
showQueryResultsPanel: false,
},
setQueryOptions: vi.fn(),
sqlEditorTransactionOptions: {
commitMode: 'manual' as 'manual' | 'auto',
autoCommitDelayMs: 0,
},
setSqlEditorTransactionOptions: vi.fn(),
sqlEditorPendingTransactions: {} as Record<string, unknown>,
setSqlEditorPendingTransaction: vi.fn(),
shortcutOptions: {
runQuery: {
mac: { enabled: false, combo: '' },
windows: { enabled: false, combo: '' },
},
selectCurrentStatement: {
mac: { enabled: false, combo: '' },
windows: { enabled: false, combo: '' },
},
saveQuery: {
mac: { enabled: true, combo: 'Meta+S' },
windows: { enabled: true, combo: 'Ctrl+S' },
},
toggleQueryResultsPanel: {
mac: { enabled: true, combo: 'Meta+Shift+M' },
windows: { enabled: true, combo: 'Ctrl+Shift+M' },
},
},
activeTabId: 'tab-1',
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
sqlSnippets: [] as any[],
}));
const storeSubscribers = vi.hoisted(() => new Set<() => void>());
const notifyStoreSubscribers = () => {
storeSubscribers.forEach((subscriber) => subscriber());
};
const backendApp = vi.hoisted(() => ({
DBQuery: vi.fn(),
DBQueryWithCancel: vi.fn(),
DBQueryMulti: vi.fn(),
DBQueryMultiTransactional: vi.fn(),
DBCommitTransaction: vi.fn(),
DBCommitTransactionWithTrigger: vi.fn(),
DBRollbackTransaction: vi.fn(),
DBRollbackTransactionWithTrigger: vi.fn(),
DBGetTables: vi.fn(),
DBGetAllColumns: vi.fn(),
DBGetDatabases: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
CancelQuery: vi.fn(),
GenerateQueryID: vi.fn(),
WriteSQLFile: vi.fn(),
ExportSQLFile: vi.fn(),
}));
const nativeDetachedWindowState = vi.hoisted(() => ({
openNativeQueryResultWindow: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}));
const dataGridState = vi.hoisted(() => ({
latestProps: null as any,
}));
const tabsState = vi.hoisted(() => ({
activeKey: undefined as string | undefined,
}));
const autoFetchState = vi.hoisted(() => ({
visible: false,
}));
const editorState = vi.hoisted(() => {
const state = {
value: '',
editor: null as any,
domNode: { style: { cursor: '' }, addEventListener: vi.fn(), removeEventListener: vi.fn() },
position: { lineNumber: 1, column: 1 },
selection: null as any,
providers: [] as any[],
hoverProviders: [] as any[],
contentChangeListeners: [] as Array<() => void>,
cursorPositionListeners: [] as Array<(event: any) => void>,
modelContentListeners: [] as Array<(event: any) => void>,
mouseMoveListeners: [] as Array<(event: any) => void>,
mouseDownListeners: [] as Array<(event: any) => void>,
mouseLeaveListeners: [] as Array<() => void>,
hasTextFocus: true,
decorationIds: [] as string[],
contentHoverCalls: [] as any[],
latestOnChange: null as null | ((value?: string) => void),
latestOptions: null as any,
};
const offsetAt = (position: { lineNumber: number; column: number }) => {
const text = state.value;
let offset = 0;
for (let lineNumber = 1; lineNumber < Math.max(1, position.lineNumber); lineNumber++) {
const nextLineBreak = text.indexOf('\n', offset);
if (nextLineBreak === -1) {
return text.length;
}
offset = nextLineBreak + 1;
}
return Math.min(text.length, offset + Math.max(0, position.column - 1));
};
const positionAt = (offset: number) => {
const text = state.value.replace(/\r\n/g, '\n');
const safeOffset = Math.max(0, Math.min(text.length, Number(offset) || 0));
const prefix = text.slice(0, safeOffset);
const lines = prefix.split('\n');
return { lineNumber: lines.length, column: (lines[lines.length - 1]?.length || 0) + 1 };
};
const valueInRange = (range: any) => {
if (!range) return '';
const start = offsetAt({ lineNumber: range.startLineNumber, column: range.startColumn });
const end = offsetAt({ lineNumber: range.endLineNumber, column: range.endColumn });
return state.value.slice(Math.min(start, end), Math.max(start, end));
};
const model = {
getValue: vi.fn(() => state.value),
getValueLength: vi.fn(() => state.value.length),
setValue: (value: string) => {
state.value = value;
},
getValueInRange: valueInRange,
getLineContent: (lineNumber: number) => state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '',
getLineCount: () => state.value.replace(/\r\n/g, '\n').split('\n').length,
getLineMaxColumn: (lineNumber: number) => (state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '').length + 1,
getWordUntilPosition: (position: { lineNumber: number; column: number }) => {
const lineContent = model.getLineContent(position.lineNumber);
const beforeCursor = lineContent.slice(0, Math.max(0, position.column - 1));
const word = beforeCursor.match(/[A-Za-z0-9_$]*$/)?.[0] || '';
return {
startColumn: position.column - word.length,
endColumn: position.column,
word,
};
},
getOffsetAt: offsetAt,
getPositionAt: positionAt,
};
state.editor = {
getValue: vi.fn(() => state.value),
setValue: vi.fn((value: string) => {
state.value = value;
}),
getModel: vi.fn(() => model),
getPosition: vi.fn(() => state.position),
setPosition: vi.fn((position: any) => {
state.position = position;
}),
getSelection: vi.fn(() => state.selection),
getDomNode: vi.fn(() => state.domNode),
getContribution: vi.fn((id: string) => {
if (id === 'editor.contrib.contentHover') {
return {
showContentHover: vi.fn((range: any, mode: any, source: any, focus: any) => {
state.contentHoverCalls.push({ range, mode, source, focus });
}),
};
}
return null;
}),
setSelection: vi.fn((selection: any) => {
state.selection = selection;
}),
executeEdits: vi.fn((_source: string, edits: any[]) => {
edits.forEach((edit) => {
const start = offsetAt({ lineNumber: edit.range.startLineNumber, column: edit.range.startColumn });
const end = offsetAt({ lineNumber: edit.range.endLineNumber, column: edit.range.endColumn });
state.value = state.value.slice(0, start) + edit.text + state.value.slice(end);
});
}),
addAction: vi.fn(),
onDidChangeModelContent: vi.fn((listener: (event?: any) => void) => {
state.contentChangeListeners.push(listener);
state.modelContentListeners.push(listener);
return { dispose: vi.fn() };
}),
onDidChangeCursorPosition: vi.fn((listener: (event: any) => void) => {
state.cursorPositionListeners.push(listener);
return { dispose: vi.fn() };
}),
onMouseMove: vi.fn((listener: (event: any) => void) => {
state.mouseMoveListeners.push(listener);
return { dispose: vi.fn() };
}),
onMouseDown: vi.fn((listener: (event: any) => void) => {
state.mouseDownListeners.push(listener);
return { dispose: vi.fn() };
}),
onMouseLeave: vi.fn((listener: () => void) => {
state.mouseLeaveListeners.push(listener);
return { dispose: vi.fn() };
}),
deltaDecorations: vi.fn((oldDecorations: string[], newDecorations: any[]) => {
state.decorationIds = newDecorations.map((_: any, index: number) => `decoration-${index + 1}`);
return state.decorationIds;
}),
updateOptions: vi.fn(),
pushUndoStop: vi.fn(),
onDidDispose: vi.fn(),
hasTextFocus: vi.fn(() => state.hasTextFocus),
revealLineInCenterIfOutsideViewport: vi.fn(),
revealRangeInCenterIfOutsideViewport: vi.fn(),
layout: vi.fn(),
focus: vi.fn(),
trigger: vi.fn(),
};
return state;
});
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: typeof storeState) => any) => React.useSyncExternalStore(
(subscriber) => {
storeSubscribers.add(subscriber);
return () => {
storeSubscribers.delete(subscriber);
};
},
() => selector(storeState),
() => selector(storeState),
),
{ getState: () => storeState },
);
return { useStore };
});
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('../utils/nativeDetachedWindowHost', () => nativeDetachedWindowState);
vi.mock('../utils/autoFetchVisibility', () => ({
useAutoFetchVisibility: () => autoFetchState.visible,
}));
vi.mock('@monaco-editor/react', () => ({
default: ({ defaultValue, onChange, onMount, options }: any) => {
React.useEffect(() => {
editorState.value = String(defaultValue || '');
editorState.latestOnChange = onChange;
editorState.latestOptions = options ?? null;
onMount?.(editorState.editor, {
editor: { setTheme: vi.fn() },
KeyMod: { CtrlCmd: 2048, WinCtrl: 256, Alt: 512, Shift: 1024 },
KeyCode: { KeyF: 70, KeyM: 77, KeyQ: 81, KeyR: 82, KeyS: 83 },
languages: {
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
CompletionItemInsertTextRule: { InsertAsSnippet: 1 },
registerCompletionItemProvider: vi.fn((_language: string, provider: any) => {
editorState.providers.push(provider);
return { dispose: vi.fn() };
}),
registerHoverProvider: vi.fn((_language: string, provider: any) => {
editorState.hoverProviders.push(provider);
return { dispose: vi.fn() };
}),
},
Range: class {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
constructor(startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
},
MarkdownString: class {
value: string;
constructor(value: string) {
this.value = value;
}
},
Position: class {
lineNumber: number;
column: number;
constructor(lineNumber: number, column: number) {
this.lineNumber = lineNumber;
this.column = column;
}
},
});
}, []);
return <textarea data-editor value={editorState.value} readOnly />;
},
}));
vi.mock('./DataGrid', () => ({
default: (props: any) => {
dataGridState.latestProps = props;
return (
<div data-grid="true">
{props.toolbarExtraActions ?? null}
</div>
);
},
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
vi.mock('./resultDiff/ResultDiffWizard', () => ({
default: () => null,
}));
vi.mock('./resultDiff/ViewDataVerifyWizard', () => ({
default: () => null,
}));
vi.mock('./LogPanel', () => ({
default: ({ executionError }: any) => (
<div data-log-panel="true">
{executionError || 'log-panel'}
</div>
),
}));
vi.mock('./DetachDragPreview', async () => {
const actual = await vi.importActual<typeof import('./DetachDragPreview')>('./DetachDragPreview');
return {
...actual,
default: () => null,
};
});
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
BugOutlined: Icon,
ClearOutlined: Icon,
CopyOutlined: Icon,
DiffOutlined: Icon,
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
SettingOutlined: Icon,
CloseOutlined: Icon,
StopOutlined: Icon,
DownOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
DatabaseOutlined: Icon,
EyeOutlined: Icon,
EyeInvisibleOutlined: Icon,
PushpinOutlined: Icon,
EnterOutlined: Icon,
EllipsisOutlined: Icon,
};
});
vi.mock('antd', () => {
const Button: any = ({ children, disabled, loading, onClick, onMouseDown, ...rest }: any) => (
<button type="button" disabled={disabled || loading} onClick={onClick} onMouseDown={onMouseDown} {...rest}>
{children}
</button>
);
Button.Group = ({ children }: any) => <div>{children}</div>;
const Space: any = ({ children }: any) => <div>{children}</div>;
Space.Compact = ({ children, className }: any) => <div className={className}>{children}</div>;
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
const Table = ({ dataSource, columns }: { dataSource: any[]; columns: any[] }) => (
<div>
{dataSource.map((record) => (
<div key={record.id}>
{columns.map((column) => (
<div key={column.dataIndex || column.title}>
{column.render
? column.render(record[column.dataIndex], record)
: record[column.dataIndex]}
</div>
))}
</div>
))}
</div>
);
const Empty = ({ description }: { description?: React.ReactNode }) => <div>{description}</div>;
(Empty as any).PRESENTED_IMAGE_SIMPLE = 'simple';
const Input: any = ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />;
Input.TextArea = ({ value, onChange, placeholder, disabled }: any) => (
<textarea value={value} onChange={onChange} placeholder={placeholder} disabled={disabled} />
);
return {
Button,
Space,
Table,
Tag: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
Empty,
message: messageApi,
Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? (
<section>
{children}
<button type="button" onClick={onOk}>{okText}</button>
</section>
) : null),
Input,
Segmented: () => null,
Form,
Dropdown: ({ children, menu }: any) => (
<>
{children}
{menu?.items?.map((item: any) => (
item?.type === 'divider'
? null
: <button key={item.key} type="button" disabled={item.disabled} onClick={item.onClick}>{item.label}</button>
))}
</>
),
Tooltip: ({ children }: any) => <>{children}</>,
Select: () => null,
Tabs: ({ activeKey, items, onChange, tabBarExtraContent }: any) => {
const resolvedActiveKey = tabsState.activeKey ?? activeKey ?? items?.[0]?.key;
const activeItem = items?.find((item: any) => item.key === resolvedActiveKey) || items?.[0];
return (
<div>
<div>
{items?.map((item: any) => (
<button
key={item.key}
type="button"
data-tab-key={item.key}
onClick={() => {
tabsState.activeKey = item.key;
onChange?.(item.key);
}}
>
{item.label}
</button>
))}
{tabBarExtraContent?.right ?? null}
</div>
<div>{activeItem?.children}</div>
</div>
);
},
};
});
const textContent = (node: any): string => {
if (node == null) return '';
if (typeof node === 'string') return node;
if (Array.isArray(node)) return node.map((item) => textContent(item)).join('');
return (node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
};
const findButtons = (renderer: ReactTestRenderer, text: string) => {
const visibleTextMatches = renderer.root.findAll(
(node) => node.type === 'button' && textContent(node).includes(text),
);
return visibleTextMatches.length > 0
? visibleTextMatches
: renderer.root.findAll((node) => (
node.type === 'button' && String(node.props?.['aria-label'] || '').includes(text)
));
};
const findButton = (renderer: ReactTestRenderer, text: string) => findButtons(renderer, text)[0];
const findResultMessageTextarea = (renderer: ReactTestRenderer, mode: 'compact' | 'full' = 'full') =>
renderer.root.find((node) =>
node.type === 'textarea' && node.props['data-query-result-message-textarea'] === mode,
);
const findByClassName = (renderer: ReactTestRenderer, className: string) =>
renderer.root.find((node) =>
typeof node.props?.className === 'string' && node.props.className.includes(className),
);
const findEditorAction = (id: string) =>
editorState.editor.addAction.mock.calls
.map((call: any[]) => call[0])
.reverse()
.find((action: any) => action?.id === id);
const createRunShortcutEvent = () => {
const isMacRuntime = /(Mac|iPhone|iPad|iPod)/i.test(`${navigator.platform || ''} ${navigator.userAgent || ''}`);
return {
ctrlKey: !isMacRuntime,
metaKey: isMacRuntime,
altKey: false,
shiftKey: false,
key: 'Enter',
target: null,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
};
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
id: 'tab-1',
title: 'query.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: 'select 1;',
...overrides,
});
const createDefaultConnections = () => ([
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
]);
describe('QueryEditor external SQL save', () => {
it('does not start result-tab detaching from close icons or portal menu items', () => {
const tabContent = {
closest: vi.fn(() => null),
} as unknown as EventTarget;
const closeIconSvg = {
closest: vi.fn((selector: string) =>
selector.includes('.query-result-tab-close') ? { className: 'query-result-tab-close' } : null),
} as unknown as EventTarget;
const contextMenuItem = {
closest: vi.fn((selector: string) =>
selector.includes('[role="menuitem"]') ? { role: 'menuitem' } : null),
} as unknown as EventTarget;
expect(shouldActivateResultTabDetachPointer({ button: 0, target: tabContent })).toBe(true);
expect(shouldActivateResultTabDetachPointer({ button: 0, target: closeIconSvg })).toBe(false);
expect(shouldActivateResultTabDetachPointer({ button: 0, target: contextMenuItem })).toBe(false);
expect(shouldActivateResultTabDetachPointer({ button: 2, target: tabContent })).toBe(false);
});
it('closes the active result tab without capturing a close-icon pointer', async () => {
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const onCloseResult = vi.fn();
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<QueryEditorResultsPanel
resultSets={[{
key: 'result-1',
sql: 'select 1',
rows: [{ value: 1 }],
columns: ['value'],
pkColumns: [],
readOnly: true,
}]}
activeResultKey="result-1"
isActive
loading={false}
executionError=""
sqlLogCount={1}
darkMode={false}
isV2Ui
currentDb="main"
currentConnectionId="conn-1"
toggleShortcutLabel=""
onActiveResultKeyChange={vi.fn()}
onHide={vi.fn()}
onCloseResult={onCloseResult}
onCloseOtherResultTabs={vi.fn()}
onCloseResultTabsToLeft={vi.fn()}
onCloseResultTabsToRight={vi.fn()}
onCloseAllResultTabs={vi.fn()}
onResultPinnedChange={vi.fn()}
onOpenResultInWindow={vi.fn()}
onReloadResult={vi.fn()}
onResultPageChange={vi.fn()}
onResultSort={vi.fn()}
onDiagnoseExecutionError={vi.fn()}
/>,
);
});
const resultTabLabel = renderer.root.findAll((node) =>
typeof node.props?.onPointerDown === 'function'
&& String(node.props?.className || '').split(/\s+/).includes('query-result-tab-label'),
)[0];
const closeButton = findByClassName(renderer, 'query-result-tab-close');
const closeIconSvg = {
closest: vi.fn((selector: string) =>
selector.includes('.query-result-tab-close') ? { className: 'query-result-tab-close' } : null),
} as unknown as EventTarget;
const setPointerCapture = vi.fn();
resultTabLabel.props.onPointerDown({
button: 0,
isPrimary: true,
target: closeIconSvg,
currentTarget: { setPointerCapture },
});
expect(setPointerCapture).not.toHaveBeenCalled();
const pointerStopPropagation = vi.fn();
closeButton.props.onPointerDown({ stopPropagation: pointerStopPropagation });
expect(pointerStopPropagation).toHaveBeenCalledOnce();
closeButton.props.onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(onCloseResult).toHaveBeenCalledWith('result-1');
await act(async () => {
renderer.unmount();
});
});
beforeEach(() => {
const completionState = (globalThis as any).__gonaviSqlCompletionState;
if (completionState) {
completionState.registered = false;
completionState.disposables = [];
}
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 1;
}),
cancelAnimationFrame: vi.fn(),
innerHeight: 900,
});
vi.stubGlobal('document', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', {
platform: 'MacIntel',
userAgent: 'Vitest',
});
setCurrentLanguage('zh-CN');
storeState.languagePreference = 'zh-CN';
storeState.shortcutOptions.runQuery.mac = { enabled: false, combo: '' };
storeState.shortcutOptions.runQuery.windows = { enabled: false, combo: '' };
storeState.shortcutOptions.selectCurrentStatement.mac = { enabled: false, combo: '' };
storeState.shortcutOptions.selectCurrentStatement.windows = { enabled: false, combo: '' };
storeState.shortcutOptions.saveQuery.mac = { enabled: true, combo: 'Meta+S' };
storeState.shortcutOptions.saveQuery.windows = { enabled: true, combo: 'Ctrl+S' };
storeState.addTab.mockReset();
storeState.setActiveContext.mockReset();
storeState.saveQuery.mockReset();
storeState.saveQuery.mockImplementation(async (query: SavedQuery) => query);
storeState.savedQueries = [];
storeState.activeTabId = 'tab-1';
storeState.aiPanelVisible = false;
storeState.setAIPanelVisible.mockReset();
storeState.queryOptions = {
maxRows: 5000,
showColumnComment: true,
showColumnType: true,
showQueryResultsPanel: false,
};
storeState.sqlEditorTransactionOptions = {
commitMode: 'manual',
autoCommitDelayMs: 0,
};
storeState.shortcutOptions = {
runQuery: {
mac: { enabled: false, combo: '' },
windows: { enabled: false, combo: '' },
},
selectCurrentStatement: {
mac: { enabled: false, combo: '' },
windows: { enabled: false, combo: '' },
},
saveQuery: {
mac: { enabled: true, combo: 'Meta+S' },
windows: { enabled: true, combo: 'Ctrl+S' },
},
toggleQueryResultsPanel: {
mac: { enabled: true, combo: 'Meta+Shift+M' },
windows: { enabled: true, combo: 'Ctrl+Shift+M' },
},
};
storeState.setQueryOptions.mockReset();
storeState.setQueryOptions.mockImplementation((options: Record<string, unknown>) => {
storeState.queryOptions = { ...storeState.queryOptions, ...options };
});
storeState.setSqlEditorTransactionOptions.mockReset();
storeState.setSqlEditorTransactionOptions.mockImplementation((options: Record<string, unknown>) => {
storeState.sqlEditorTransactionOptions = { ...storeState.sqlEditorTransactionOptions, ...options };
});
storeState.sqlEditorPendingTransactions = {};
storeState.setSqlEditorPendingTransaction.mockReset();
storeState.setSqlEditorPendingTransaction.mockImplementation((tabId: string, transaction: unknown) => {
if (!transaction) {
delete storeState.sqlEditorPendingTransactions[tabId];
return;
}
storeState.sqlEditorPendingTransactions[tabId] = transaction;
});
Object.values(backendApp).forEach((fn) => fn.mockReset());
nativeDetachedWindowState.openNativeQueryResultWindow.mockReset();
nativeDetachedWindowState.openNativeQueryResultWindow.mockResolvedValue(false);
messageApi.success.mockReset();
messageApi.error.mockReset();
messageApi.info.mockReset();
messageApi.warning.mockReset();
backendApp.DBQuery.mockResolvedValue({ success: true, data: [] });
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
backendApp.ExportSQLFile.mockResolvedValue({ success: true });
backendApp.DBQueryWithCancel.mockResolvedValue({ success: true, data: [] });
backendApp.DBQueryMulti.mockResolvedValue({ success: true, data: [] });
backendApp.DBQueryMultiTransactional.mockResolvedValue({ success: true, data: [] });
backendApp.DBCommitTransaction.mockResolvedValue({ success: true, message: '事务已提交' });
backendApp.DBCommitTransactionWithTrigger.mockResolvedValue({ success: true, message: '事务已提交' });
backendApp.DBRollbackTransaction.mockResolvedValue({ success: true, message: '事务已回滚' });
backendApp.DBRollbackTransactionWithTrigger.mockResolvedValue({ success: true, message: '事务已回滚' });
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetAllColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetDatabases.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetTables.mockResolvedValue({ success: true, data: [] });
backendApp.GenerateQueryID.mockResolvedValue('query-1');
storeState.connections = createDefaultConnections();
storeState.sqlLogs = [];
storeState.clearSqlLogs.mockReset();
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
storeState.fontSize = 14;
storeState.appearance.uiVersion = 'legacy';
storeState.appearance.customMonoFontFamily = null;
storeState.appearance.dataTableFontSize = null;
storeState.appearance.dataTableFontSizeFollowGlobal = true;
storeState.appearance.sqlEditorFontSize = null;
storeState.appearance.sqlEditorFontSizeFollowGlobal = true;
autoFetchState.visible = false;
dataGridState.latestProps = null;
tabsState.activeKey = undefined;
editorState.value = '';
editorState.position = { lineNumber: 1, column: 1 };
editorState.selection = null;
editorState.domNode.style.cursor = '';
editorState.providers = [];
editorState.hoverProviders = [];
editorState.contentChangeListeners = [];
editorState.cursorPositionListeners = [];
editorState.modelContentListeners = [];
editorState.mouseMoveListeners = [];
editorState.mouseDownListeners = [];
editorState.mouseLeaveListeners = [];
editorState.hasTextFocus = true;
editorState.decorationIds = [];
editorState.contentHoverCalls = [];
editorState.latestOnChange = null;
editorState.latestOptions = null;
editorState.editor.getValue.mockClear();
editorState.editor.getModel().getValue.mockClear();
editorState.editor.getModel().getValueLength.mockClear();
editorState.editor.setValue.mockClear();
editorState.editor.executeEdits.mockClear();
editorState.editor.deltaDecorations.mockClear();
editorState.editor.updateOptions.mockClear();
editorState.editor.pushUndoStop.mockClear();
editorState.editor.addAction.mockClear();
storeState.updateQueryTabDraft.mockReset();
storeSubscribers.clear();
editorState.editor.layout.mockClear();
clearQueryTabDraft('tab-1');
clearQueryTabDraft('tab-2');
clearSQLFileTabDraft('tab-1');
clearSQLFileTabDraft('tab-2');
});
afterEach(() => {
act(() => {
[...mountedRenderers].forEach((renderer) => renderer.unmount());
});
clearQueryEditorResultSession('tab-1');
clearQueryEditorResultSession('tab-2');
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('keeps Oracle anonymous PL/SQL blocks intact when running from the editor', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMultiTransactional.mockResolvedValueOnce({
success: true,
transactionId: 'tx-oracle-block',
transactionPending: true,
data: [{ columns: ['affectedRows'], rows: [{ affectedRows: 1 }] }],
});
const plsql = [
'BEGIN',
" INSERT INTO tmp_disable_trigger (table_name) VALUES ('t_memcard_reg');",
" UPDATE t_memcard_reg SET CARDLEVEL = 1 WHERE MEMCARDNO = '8032277312';",
" DELETE FROM tmp_disable_trigger WHERE table_name = 't_memcard_reg';",
'END;',
].join('\n');
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ORCLPDB1', query: plsql })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMultiTransactional).toHaveBeenCalledWith(expect.anything(), 'ORCLPDB1', plsql, 'query-1');
expect(backendApp.DBQueryMulti).not.toHaveBeenCalled();
expect(storeState.sqlEditorPendingTransactions['tab-1']).toMatchObject({
id: 'tx-oracle-block',
dbType: 'oracle',
statements: [plsql],
});
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: plsql,
status: 'success',
}));
renderer?.unmount();
});
it('executes a long commented Oracle anonymous block without blocking the UI thread', async () => {
storeState.appearance.uiVersion = 'v2';
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
const columns = Array.from(
{ length: 42 },
(_, index) => ` column_${index + 1} VARCHAR2(100) DEFAULT 'value_${index + 1}'`,
).join(',\n');
const sql = [
'-- ------------------------------------------------------------',
'-- Long Oracle anonymous setup block',
'-- ------------------------------------------------------------',
'DECLARE',
' v_cnt NUMBER;',
'BEGIN',
' SELECT COUNT(1) INTO v_cnt',
' FROM user_tables',
" WHERE table_name = 'GONAVI_REPRO_TABLE';",
' IF v_cnt = 0 THEN',
" EXECUTE IMMEDIATE '\n CREATE TABLE gonavi_repro_table (\n" + columns + "\n )\n ';",
' END IF;',
'END;',
'/',
].join('\n');
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: Array.from({ length: 52 }, (_, index) => ({
statementIndex: index + 1,
columns: ['affectedRows'],
rows: [{ affectedRows: 0 }],
})),
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ORCLPDB1', query: sql })} />);
});
const runButton = findByClassName(renderer, 'gn-v2-query-toolbar-run-action');
await act(async () => {
await runButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const resultTabs = renderer.root.findAll((node) =>
node.type === 'button' && String(node.props?.['data-tab-key'] || '').startsWith('result-'),
);
expect(backendApp.DBQueryMulti).toHaveBeenCalledOnce();
expect(backendApp.DBGetColumns).not.toHaveBeenCalled();
expect(backendApp.DBGetIndexes).not.toHaveBeenCalled();
expect(resultTabs).toHaveLength(52);
await act(async () => {
renderer.unmount();
});
});
it('runs the whole Oracle procedure when the cursor is in the exception tail', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['affectedRows'], rows: [{ affectedRows: 1 }] }],
});
const plsql = [
'-- 修改函数/存储过程H2.cproc_tzhssr_order2sale_A1',
'-- 请确认语法兼容当前数据库后执行',
'CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1(',
' p_sourceid IN VARCHAR2,',
' p_msg_out OUT NVARCHAR2',
') AS',
' v_ecnt NUMBER;',
' CURSOR cur_ware IS',
' SELECT d.goodsid',
' FROM t_order_d d',
' ORDER BY CASE',
" WHEN d.goodsqty > 0 THEN '1'",
" ELSE '2'",
' END, d.goodsid;',
'BEGIN',
' FOR row_ware IN cur_ware LOOP',
' IF row_ware.goodsid IS NOT NULL THEN',
' BEGIN',
' SELECT COUNT(*) INTO v_ecnt FROM dual;',
' EXCEPTION',
' WHEN no_data_found THEN',
' v_ecnt := 0;',
' END;',
' END IF;',
' END LOOP;',
" p_msg_out := '';",
'EXCEPTION',
' WHEN OTHERS THEN',
" p_msg_out := substr('订单核销失败,错误信息:' || SQLERRM || ',错误位置:' ||",
' dbms_utility.format_error_backtrace, 1, 1000);',
'END cproc_tzhssr_order2sale_A1;',
'/;',
].join('\n');
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ORCLPDB1', query: plsql, queryMode: 'object-edit' })} />);
});
const tailLine = plsql.split('\n').findIndex((line) => line.includes('p_msg_out := substr')) + 1;
editorState.position = { lineNumber: tailLine, column: 5 };
editorState.selection = {
startLineNumber: tailLine,
startColumn: 5,
endLineNumber: tailLine,
endColumn: 5,
positionLineNumber: tailLine,
positionColumn: 5,
};
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]);
expect(executedSql).toContain('CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1');
expect(executedSql).toContain('p_msg_out OUT NVARCHAR2');
expect(executedSql).toContain('p_msg_out := substr');
expect(executedSql).not.toBe(plsql.split('\n').slice(tailLine - 1).join('\n'));
expect(executedSql).not.toContain('/;');
renderer?.unmount();
});
it('runs the preceding Oracle procedure when the cursor is on the SQLPlus slash delimiter', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['affectedRows'], rows: [{ affectedRows: 1 }] }],
});
const plsql = [
'CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1(',
' p_sourceid IN VARCHAR2,',
' p_msg_out OUT NVARCHAR2',
') AS',
'BEGIN',
" p_msg_out := '';",
'EXCEPTION',
' WHEN OTHERS THEN',
' p_msg_out := SQLERRM;',
'END cproc_tzhssr_order2sale_A1;',
'/;',
].join('\n');
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ORCLPDB1', query: plsql, queryMode: 'object-edit' })} />);
});
const slashLine = plsql.split('\n').findIndex((line) => line.startsWith('/')) + 1;
editorState.position = { lineNumber: slashLine, column: 1 };
editorState.selection = {
startLineNumber: slashLine,
startColumn: 1,
endLineNumber: slashLine,
endColumn: 1,
positionLineNumber: slashLine,
positionColumn: 1,
};
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]);
expect(executedSql).toContain('CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1');
expect(executedSql).toContain('p_msg_out OUT NVARCHAR2');
expect(executedSql).toContain('END cproc_tzhssr_order2sale_A1;');
expect(executedSql).not.toContain('/;');
renderer?.unmount();
});
it('renders result grid for sqlserver exec statements that return rows', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['SPID', 'STATUS'], rows: [{ SPID: 52, STATUS: 'RUNNABLE' }] }],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'master', query: 'EXEC sp_who2' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).not.toContain('影响行数:');
expect(dataGridState.latestProps?.columnNames).toEqual(['SPID', 'STATUS']);
expect(Array.isArray(dataGridState.latestProps?.data)).toBe(true);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ SPID: 52, STATUS: 'RUNNABLE' });
});
it('renders SQLite select results even when the result panel starts hidden', async () => {
storeState.connections[0].config.type = 'sqlite';
storeState.connections[0].config.database = 'main';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['id', 'name'], rows: [{ id: 1, name: 'SQLite row' }] }],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: "SELECT 1 AS id, 'SQLite row' AS name" })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(dataGridState.latestProps?.columnNames).toEqual(['id', 'name']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ id: 1, name: 'SQLite row' });
renderer.unmount();
});
it('renders standalone message result for sqlserver statistics statements', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: [],
rows: [],
messages: ["Table 'users'. Scan count 1, logical reads 3."],
}],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'master', query: 'SET STATISTICS IO ON;' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('消息 1');
expect(findResultMessageTextarea(renderer!).props.value).toBe("Table 'users'. Scan count 1, logical reads 3.");
expect(renderer!.root.findAll((node) => node.props?.['data-grid'] === 'true')).toHaveLength(0);
});
it('preserves sqlserver message indentation and blank lines after stripping mssql prefixes', () => {
expect(normalizeQueryResultMessages([
"mssql: select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),",
"mssql: 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving",
'',
" where funcno = @funcno and tabname = '$vendorclass'",
])).toEqual([
" select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),",
" 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving",
'',
" where funcno = @funcno and tabname = '$vendorclass'",
]);
});
it('keeps multiple result sets from a single sqlserver statement', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] },
{ statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'master', query: 'EXEC sp_helpdb' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).toContain('结果 2');
expect(dataGridState.latestProps?.columnNames).toEqual(['name']);
});
it('hides redundant sqlserver affected-row status result after a query result', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'hydee';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{
columns: ['dddwno', 'dddwlist'],
rows: [{ dddwno: '001', dddwlist: 'demo' }],
},
{ columns: ['affectedRows'], rows: [{ affectedRows: 846 }] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'hydee', query: 'select * from c_dddw' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const rendered = textContent(renderer!.toJSON());
expect(rendered).toContain('结果 1');
expect(rendered).not.toContain('结果 2');
expect(rendered).not.toContain('影响行数846');
expect(dataGridState.latestProps?.columnNames).toEqual(['dddwno', 'dddwlist']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ dddwno: '001', dddwlist: 'demo' });
expect(messageApi.success).toHaveBeenCalledWith('已执行完成,生成 1 个结果集。');
});
it('hides redundant sqlserver affected-row status results for every statement in a batch', async () => {
storeState.appearance.uiVersion = 'v2';
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['value'], rows: [{ value: 1 }] },
{ statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 1 }] },
{ statementIndex: 2, columns: ['value'], rows: [{ value: 2 }] },
{ statementIndex: 2, columns: ['affectedRows'], rows: [{ affectedRows: 1 }] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'master', query: 'SELECT 1;\nSELECT 2;' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const rendered = textContent(renderer!.toJSON());
expect(rendered).toContain('结果 1');
expect(rendered).toContain('结果 2');
expect(rendered).not.toContain('结果 3');
expect(rendered).not.toContain('结果 4');
expect(rendered).not.toContain('影响行数1');
expect(messageApi.success).toHaveBeenCalledWith('已执行完成,生成 2 个结果集。');
const resultTabButtons = renderer!.root.findAll((node) =>
node.type === 'button' && String(node.props['data-tab-key'] || '').startsWith('result-'));
expect(resultTabButtons).toHaveLength(2);
await act(async () => {
resultTabButtons[1].props.onClick();
});
expect(dataGridState.latestProps?.columnNames).toEqual(['value']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ value: 2 });
});
it('prefers the first displayable sqlserver procedure result when empty result sets are returned', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'hydee';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: [], rows: [] },
{
statementIndex: 1,
columns: ['insert_sql'],
rows: [
{ insert_sql: "insert into c_user(userid) values('168')" },
{ insert_sql: "insert into c_user(userid) values('169')" },
],
},
{ statementIndex: 1, columns: [], rows: [] },
{ statementIndex: 1, columns: [], rows: [] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'hydee', query: "p_get_select 'c_user','userid = ''168''',1" })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('结果 4');
expect(dataGridState.latestProps?.columnNames).toEqual(['insert_sql']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({
insert_sql: "insert into c_user(userid) values('168')",
});
});
it('prefers concrete sqlserver procedure rows over affected-row status results', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'hydee';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] },
{ statementIndex: 1, columns: [], rows: [] },
{
statementIndex: 1,
columns: ['insert_sql'],
rows: [
{ insert_sql: "insert into c_user(userid) values('168')" },
],
},
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'hydee', query: "p_get_select 'c_user','userid = ''168''',1" })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.columnNames).toEqual(['insert_sql']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({
insert_sql: "insert into c_user(userid) values('168')",
});
expect(textContent(renderer!.toJSON())).not.toContain('影响行数0');
});
it('shows the data result tab in V2 when the SQL log tab is already visible', async () => {
storeState.appearance.uiVersion = 'v2';
storeState.sqlLogs = [{
id: 'log-existing',
timestamp: Date.now(),
sql: 'SELECT * FROM ldf_server.mes_work_order',
status: 'success',
duration: 120,
}];
storeState.connections[0].config.type = 'kingbase';
storeState.connections[0].config.database = 'ldf_server_dbs_dev';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
statementIndex: 1,
columns: ['work_order'],
rows: [{ work_order: 'MO-20260629' }],
}],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'ldf_server_dbs_dev',
query: 'SELECT * FROM ldf_server.mes_work_order;',
resultPanelVisible: true,
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const rendered = textContent(renderer!.toJSON());
expect(rendered).toContain('日志');
expect(rendered).toContain('结果 1');
expect(dataGridState.latestProps?.columnNames).toEqual(['work_order']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ work_order: 'MO-20260629' });
});
it('prefers sqlserver print output messages over affected-row status results', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'hydee';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] },
{
statementIndex: 1,
columns: [],
rows: [],
messages: [
"insert into c_dyscript(projectid,name) values (1,'demo')",
"insert into c_dyscript(projectid,name) values (2,'next')",
],
},
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'hydee', query: "p_get_select c_dyscript,'projectid = 1',1" })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('消息 1');
expect(findResultMessageTextarea(renderer!).props.value).toBe([
"insert into c_dyscript(projectid,name) values (1,'demo')",
"insert into c_dyscript(projectid,name) values (2,'next')",
].join('\n'));
expect(textContent(renderer!.toJSON())).not.toContain('影响行数0');
expect(dataGridState.latestProps).toBeNull();
});
it('preserves sqlserver message indentation in the rendered result message textarea', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'hydee';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{
statementIndex: 1,
columns: [],
rows: [],
messages: [
"mssql: select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),",
"mssql: 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving",
'',
" where funcno = @funcno and tabname = '$vendorclass'",
],
},
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'hydee', query: "sp_sql p_get_query" })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const rendered = textContent(renderer!.toJSON());
const messageTextarea = findResultMessageTextarea(renderer!);
const messageBlock = findByClassName(renderer!, 'query-result-message-block');
const messageScrollBody = findByClassName(renderer!, 'query-result-message-scroll-body');
expect(rendered).toContain('消息 1');
expect(messageTextarea.props.value).toBe([
" select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),",
" 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving",
'',
" where funcno = @funcno and tabname = '$vendorclass'",
].join('\n'));
expect(messageTextarea.props.wrap).toBe('off');
expect(messageTextarea.props.style).toMatchObject({
display: 'block',
whiteSpace: 'pre',
overflow: 'auto',
width: '100%',
minWidth: 0,
padding: '10px 12px',
});
expect(messageTextarea.props.style.padding).not.toBe(0);
expect(messageTextarea.props.style.minWidth).not.toBe('max-content');
expect(messageBlock.props.style).toMatchObject({
alignItems: 'stretch',
width: '100%',
});
expect(messageScrollBody.props.style).toMatchObject({
display: 'flex',
alignItems: 'stretch',
width: '100%',
overflow: 'hidden',
minWidth: 0,
borderRadius: 6,
});
expect(messageScrollBody.props.style.border).toContain('1px solid');
expect(messageScrollBody.props.style.background).toBeTruthy();
expect(messageTextarea.props.value).not.toContain('mssql:');
});
it('renders top-level sqlserver print messages when result sets contain only status rows', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'hydee';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] },
],
messages: [
"insert into c_dyscript(projectid,name) values (1,'demo')",
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'hydee', query: "p_get_select c_dyscript,'projectid = 1',1" })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('消息 1');
expect(findResultMessageTextarea(renderer!).props.value).toBe("insert into c_dyscript(projectid,name) values (1,'demo')");
expect(textContent(renderer!.toJSON())).not.toContain('影响行数0');
expect(dataGridState.latestProps).toBeNull();
});
it('keeps both tabs when rerunning the same single sqlserver statement with multiple result sets', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] },
{ statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] },
],
})
.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['name'], rows: [{ name: 'tempdb' }] },
{ statementIndex: 1, columns: ['owner'], rows: [{ owner: 'dbo' }] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'master', query: 'EXEC sp_helpdb' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const tabLabels = renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
});
expect(tabLabels).toHaveLength(2);
expect(dataGridState.latestProps?.columnNames).toEqual(['name']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ name: 'tempdb' });
});
it('reloads the active secondary result set for a single sqlserver statement', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] },
{ statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] },
],
})
.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] },
{ statementIndex: 1, columns: ['owner'], rows: [{ owner: 'dbo' }] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'master', query: 'EXEC sp_helpdb' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const resultTabButtons = renderer!.root.findAll((node) => node.type === 'button' && node.props['data-tab-key']);
expect(resultTabButtons).toHaveLength(2);
await act(async () => {
resultTabButtons[1].props.onClick();
});
expect(dataGridState.latestProps?.columnNames).toEqual(['owner']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ owner: 'sa' });
await act(async () => {
await dataGridState.latestProps?.onReload?.();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(dataGridState.latestProps?.columnNames).toEqual(['owner']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ owner: 'dbo' });
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'master' })]));
});
it('localizes the non-Oracle all-columns locator warning in English while preserving the raw table name', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM users' })} />);
});
await act(async () => {
await findButton(renderer!, 'Run').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'all-columns',
readOnly: false,
reason: 'No primary key or unique index was detected, so rows will be located by matching all columns. Edit with care.',
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalledWith(
'Query results remain read-only: main.users No primary key or usable unique index was detected, so changes cannot be committed safely.',
);
expect(messageApi.warning).not.toHaveBeenCalledWith(
'查询结果保持只读main.users 未检测到主键或可用唯一索引,无法安全提交修改。',
);
});
it('uses all-columns editing when non-Oracle unique-index metadata is unavailable', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'NAME', key: '' }],
});
backendApp.DBGetIndexes.mockResolvedValueOnce({
success: false,
data: [],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM users' })} />);
});
await act(async () => {
await findButton(renderer!, 'Run').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'all-columns',
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalledWith(
'Query results remain read-only: main.users Unable to load unique index metadata, so changes cannot be committed safely.',
);
expect(messageApi.warning).not.toHaveBeenCalledWith(
'查询结果保持只读main.users 无法加载唯一索引元数据,无法安全提交修改。',
);
});
it('uses all-columns editing when non-Oracle table locator metadata is unavailable', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: false,
data: [],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM users' })} />);
});
await act(async () => {
await findButton(renderer!, 'Run').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'all-columns',
columns: [],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalledWith(
'Query results remain read-only: Unable to load primary key/unique index metadata for main.users, so changes cannot be committed safely.',
);
expect(messageApi.warning).not.toHaveBeenCalledWith(
'查询结果保持只读:无法加载 main.users 的主键/唯一索引元数据,无法安全提交修改。',
);
});
it('falls back to all-columns editing when query locator metadata stalls', async () => {
vi.useFakeTimers();
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME'], rows: [{ NAME: 'alpha' }] }],
});
backendApp.DBGetColumns.mockReturnValueOnce(new Promise(() => {}));
backendApp.DBGetIndexes.mockReturnValueOnce(new Promise(() => {}));
try {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM users' })} />);
});
await act(async () => {
findButton(renderer!, '运行').props.onClick();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(2000);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(
expect.anything(),
'main',
'SELECT NAME FROM users LIMIT 5000',
'query-1',
);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ NAME: 'alpha' });
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'all-columns',
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('keeps MySQL information_schema routine results read-only without a locator warning', async () => {
const sql = [
'SELECT ROUTINE_SCHEMA, ROUTINE_NAME, DEFINER, SECURITY_TYPE',
'FROM information_schema.ROUTINES',
"WHERE ROUTINE_SCHEMA = 'mkefu_location_dev_local'",
" AND ROUTINE_NAME = 'init_orgi'",
].join('\n');
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: ['ROUTINE_SCHEMA', 'ROUTINE_NAME', 'DEFINER', 'SECURITY_TYPE'],
rows: [{
ROUTINE_SCHEMA: 'mkefu_location_dev_local',
ROUTINE_NAME: 'init_orgi',
DEFINER: 'root@%',
SECURITY_TYPE: 'DEFINER',
}],
}],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'mkefu_location_dev_local', query: sql })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('ROUTINES');
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(backendApp.DBGetColumns).not.toHaveBeenCalled();
expect(backendApp.DBGetIndexes).not.toHaveBeenCalled();
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('runs the SQL statement at the cursor instead of the whole editor when nothing is selected', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as two;\nselect 3;',
})} />);
});
editorState.position = { lineNumber: 2, column: 8 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.();
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: expect.stringContaining('select 2 as two'),
}));
});
it('keeps cursor statement execution available in v2 UI', async () => {
storeState.appearance.uiVersion = 'v2';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as two;\nselect 3;',
})} />);
});
editorState.position = { lineNumber: 2, column: 8 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.();
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('registers Windows Ctrl+R with Monaco CtrlCmd and runs the selected SQL', async () => {
vi.stubGlobal('navigator', {
platform: 'Win32',
userAgent: 'Vitest',
});
storeState.shortcutOptions.runQuery.windows = { enabled: true, combo: 'Ctrl+R' };
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn((event: Event) => {
windowListeners[event.type]?.forEach((listener) => listener(event));
return true;
}),
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 1;
}),
cancelAnimationFrame: vi.fn(),
innerHeight: 900,
});
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['total'], rows: [{ total: 1 }] }],
});
await act(async () => {
create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect count(*) as total from messages;\nselect 3;',
})} />);
});
editorState.selection = {
startLineNumber: 2,
startColumn: 1,
endLineNumber: 2,
endColumn: 'select count(*) as total from messages'.length + 1,
};
const runAction = findEditorAction('gonavi.runQuery');
await act(async () => {
await runAction.run();
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(
expect.anything(),
'main',
expect.stringContaining('select count(*) as total from messages'),
'query-1',
);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('runs the cursor SQL from the run shortcut when nothing is selected', async () => {
storeState.shortcutOptions.runQuery.mac = { enabled: true, combo: 'Meta+Enter' };
storeState.shortcutOptions.runQuery.windows = { enabled: true, combo: 'Ctrl+Enter' };
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 1;
}),
cancelAnimationFrame: vi.fn(),
innerHeight: 900,
});
await act(async () => {
create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as two;\nselect 3;',
})} />);
});
editorState.position = { lineNumber: 2, column: 8 };
editorState.selection = null;
editorState.cursorPositionListeners.forEach((listener) => {
listener({ position: editorState.position });
});
backendApp.DBQueryMulti.mockClear();
const event = createRunShortcutEvent();
await act(async () => {
windowListeners.keydown?.forEach((listener) => listener(event));
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(
expect.anything(),
'main',
expect.stringContaining('select 2 as two'),
'query-1',
);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('runs selected SQL from the run shortcut', async () => {
storeState.shortcutOptions.runQuery.mac = { enabled: true, combo: 'Meta+Enter' };
storeState.shortcutOptions.runQuery.windows = { enabled: true, combo: 'Ctrl+Enter' };
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 1;
}),
cancelAnimationFrame: vi.fn(),
innerHeight: 900,
});
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
await act(async () => {
create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as two;\nselect 3;',
})} />);
});
editorState.position = { lineNumber: 1, column: 4 };
editorState.selection = {
startLineNumber: 2,
startColumn: 1,
endLineNumber: 2,
endColumn: 'select 2 as two'.length + 1,
};
const event = createRunShortcutEvent();
await act(async () => {
windowListeners.keydown?.forEach((listener) => listener(event));
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('renders the zero-count V2 SQL log tab for the active non-Chinese language', async () => {
storeState.appearance.uiVersion = 'v2';
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ resultPanelVisible: true })} />);
});
const rendered = textContent(renderer!.toJSON());
expect(rendered).toContain('Logs0');
expect(renderer!.root.findAll((node) => node.props?.['data-log-panel'] === 'true')).toHaveLength(1);
expect(renderer!.root.findAll((node) =>
node.props?.['data-tab-key'] === QUERY_EDITOR_SQL_LOG_TAB_KEY,
)).toHaveLength(1);
expect(rendered).not.toContain('日志0');
await act(async () => {
renderer!.unmount();
});
});
it('uses the last editor cursor position when the run button takes focus', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.cursorPositionListeners.forEach((listener) => {
listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } });
});
editorState.hasTextFocus = false;
editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
});
it('prefers the last editor cursor event even if Monaco still reports text focus', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.cursorPositionListeners.forEach((listener) => {
listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } });
});
editorState.hasTextFocus = true;
editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
});
it('uses Monaco active selection position when run button focus drifts onto a blank line', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\n\nselect 3 as c;',
})} />);
});
editorState.selection = {
startLineNumber: 2,
startColumn: 'select 2 as b;'.length + 1,
endLineNumber: 2,
endColumn: 'select 2 as b;'.length + 1,
positionLineNumber: 2,
positionColumn: 'select 2 as b;'.length + 1,
};
editorState.position = { lineNumber: 3, column: 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。');
});
it('keeps cursor statement execution when CRLF line endings put the cursor after a semicolon', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\r\nselect 2 as b;\r\n\r\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 };
editorState.selection = {
startLineNumber: 2,
startColumn: 'select 2 as b;'.length + 1,
endLineNumber: 2,
endColumn: 'select 2 as b;'.length + 1,
positionLineNumber: 2,
positionColumn: 'select 2 as b;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。');
});
it('executes all SQL when the cursor is on a blank line', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\n\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
editorState.selection = {
startLineNumber: 1,
startColumn: 'select 1 as a;'.length + 1,
endLineNumber: 1,
endColumn: 'select 1 as a;'.length + 1,
positionLineNumber: 1,
positionColumn: 'select 1 as a;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, 'Run');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('Result 1');
backendApp.DBQueryMulti.mockClear();
messageApi.info.mockClear();
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
});
editorState.position = { lineNumber: 3, column: 1 };
editorState.selection = {
startLineNumber: 3,
startColumn: 1,
endLineNumber: 3,
endColumn: 1,
positionLineNumber: 3,
positionColumn: 1,
};
editorState.cursorPositionListeners.forEach((listener) => {
listener({ position: { lineNumber: 3, column: 1 } });
});
await act(async () => {
const runButton = findButton(renderer!, 'Run');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1);
const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]);
expect(executedSql).toContain('select 1 as a');
expect(executedSql).toContain('select 2 as b');
expect(executedSql).toContain('select 3 as c');
expect(messageApi.info).not.toHaveBeenCalledWith('No executable SQL.');
expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。');
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })]));
});
it('shows "Select a database first." in English before running without a database', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: '', query: 'select 1;' })} />);
});
await act(async () => {
await findButton(renderer, 'Run').props.onClick();
});
expect(messageApi.error).toHaveBeenCalledWith('Select a database first.');
expect(messageApi.error).not.toHaveBeenCalledWith('请先选择数据库');
});
it('shows "Connection not found." in English before running without a matching connection', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
storeState.connections = [];
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ connectionId: 'missing', dbName: 'main', query: 'select 1;' })} />);
});
await act(async () => {
await findButton(renderer, 'Run').props.onClick();
});
expect(messageApi.error).toHaveBeenCalledWith('Connection not found.');
expect(messageApi.error).not.toHaveBeenCalledWith('Connection not found');
});
it('shows the unsupported source guard in English before running', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
storeState.connections[0].config.type = 'redis';
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'select 1;' })} />);
});
await act(async () => {
await findButton(renderer, 'Run').props.onClick();
});
expect(messageApi.error).toHaveBeenCalledWith(
'This data source does not support the SQL query editor. Use its dedicated page instead.',
);
expect(messageApi.error).not.toHaveBeenCalledWith('当前数据源不支持 SQL 查询编辑器,请使用对应专用页面。');
});
describe('execution toast localization', () => {
it('shows the Mongo multi-statement success toast in English', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
storeState.connections[0].config.type = 'mongodb';
const query = 'db.users.find({});\ndb.logs.find({});';
backendApp.DBQueryWithCancel
.mockResolvedValueOnce({ success: true, data: [{ _id: 1 }], fields: ['_id'] })
.mockResolvedValueOnce({ success: true, data: [{ _id: 2 }], fields: ['_id'] });
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query })} />);
});
editorState.selection = {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 2,
endColumn: 'db.logs.find({});'.length + 1,
positionLineNumber: 2,
positionColumn: 'db.logs.find({});'.length + 1,
};
await act(async () => {
await findButton(renderer, 'Run').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(messageApi.success).toHaveBeenCalledWith('Executed 2 statements and produced 2 result sets.');
expect(messageApi.success).not.toHaveBeenCalledWith('已执行 2 条语句,生成 2 个结果集。');
});
it('shows the Mongo multi-statement failure prefix localization in English', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
storeState.connections[0].config.type = 'mongodb';
const query = 'db.users.find({});\ndb.logs.find({});';
backendApp.DBQueryWithCancel
.mockResolvedValueOnce({ success: true, data: [{ _id: 1 }], fields: ['_id'] })
.mockResolvedValueOnce({ success: false, message: 'driver exploded' });
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query })} />);
});
editorState.selection = {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 2,
endColumn: 'db.logs.find({});'.length + 1,
positionLineNumber: 2,
positionColumn: 'db.logs.find({});'.length + 1,
};
await act(async () => {
await findButton(renderer, 'Run').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const rendered = textContent(renderer.toJSON());
expect(rendered).toContain(formatSqlExecutionError('driver exploded', {
prefix: 'Statement 2 failed:',
}));
expect(rendered).not.toContain('第 2 条语句执行失败driver exploded');
});
it('shows the Mongo zero-result success toast in English', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
storeState.connections[0].config.type = 'mongodb';
const query = '{"ping":1}';
backendApp.DBQueryWithCancel.mockResolvedValueOnce({ success: true, data: { ok: 1 } });
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query })} />);
});
editorState.position = { lineNumber: 1, column: query.length + 1 };
await act(async () => {
await findButton(renderer, 'Run').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(messageApi.success).toHaveBeenCalledWith('Execution succeeded.');
expect(messageApi.success).not.toHaveBeenCalledWith('执行成功。');
});
it('shows the non-Mongo multi-result success toast in English', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
const query = 'select 1 as a; select 2 as b;';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ columns: ['a'], rows: [{ a: 1 }] },
{ columns: ['b'], rows: [{ b: 2 }] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query })} />);
});
editorState.selection = {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: query.length + 1,
positionLineNumber: 1,
positionColumn: query.length + 1,
};
await act(async () => {
const runButton = findButton(renderer, 'Run');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled();
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 2 as b');
expect(messageApi.success).toHaveBeenCalledWith('Execution finished and produced 2 result sets.');
expect(messageApi.success).not.toHaveBeenCalledWith('已执行完成,生成 2 个结果集。');
});
it('shows the non-Mongo zero-result success toast in English', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
const query = 'update users set active = 1 where 1 = 0;';
backendApp.DBQueryMultiTransactional.mockResolvedValueOnce({ success: true, data: [] });
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query })} />);
});
editorState.selection = {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: query.length + 1,
positionLineNumber: 1,
positionColumn: query.length + 1,
};
await act(async () => {
const runButton = findButton(renderer, 'Run');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled();
expect(backendApp.DBQueryMulti).not.toHaveBeenCalled();
expect(backendApp.DBQueryMultiTransactional).toHaveBeenCalledTimes(1);
expect(String(backendApp.DBQueryMultiTransactional.mock.calls[0][2])).toContain('update users set active = 1 where 1 = 0');
expect(messageApi.success).toHaveBeenCalledWith('Execution succeeded.');
expect(messageApi.success).not.toHaveBeenCalledWith('执行成功。');
});
it('shows the wrapped execution failure toast in English while preserving raw error detail', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
const query = 'select 1;';
backendApp.DBQueryMulti.mockRejectedValueOnce(new Error('driver exploded'));
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query })} />);
});
editorState.selection = {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: query.length + 1,
positionLineNumber: 1,
positionColumn: query.length + 1,
};
await act(async () => {
const runButton = findButton(renderer, 'Run');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled();
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1');
expect(messageApi.error).toHaveBeenCalledWith(`Query execution failed: ${formatSqlExecutionError('driver exploded')}`);
expect(messageApi.error).not.toHaveBeenCalledWith('Error executing query: driver exploded');
});
});
describe('result refresh toast localization', () => {
const renderAndRunQuery = async (query: string) => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query })} />);
});
editorState.selection = {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: query.length + 1,
positionLineNumber: 1,
positionColumn: query.length + 1,
};
await act(async () => {
const runButton = findButton(renderer, 'Run');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.onReload).toEqual(expect.any(Function));
};
it('shows the response refresh failure toast in English while preserving raw error detail', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
const query = 'select 1 as a;';
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
})
.mockResolvedValueOnce({ success: false, message: 'network down' });
await renderAndRunQuery(query);
messageApi.error.mockClear();
await act(async () => {
await dataGridState.latestProps.onReload();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(messageApi.error).toHaveBeenCalledWith(`Refresh failed: ${formatSqlExecutionError('network down')}`);
expect(messageApi.error).not.toHaveBeenCalledWith('刷新失败: network down');
});
it('shows the rejected refresh failure toast in English while preserving raw error detail', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
const query = 'select 1 as a;';
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
})
.mockRejectedValueOnce(new Error('socket closed'));
await renderAndRunQuery(query);
messageApi.error.mockClear();
await act(async () => {
await dataGridState.latestProps.onReload();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(messageApi.error).toHaveBeenCalledWith(`Refresh failed: ${formatSqlExecutionError('socket closed')}`);
expect(messageApi.error).not.toHaveBeenCalledWith('刷新失败: socket closed');
});
});
it('shows "No running query to cancel." in English when stop is clicked before a query id exists', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
backendApp.GenerateQueryID.mockReturnValueOnce(new Promise(() => {}));
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'select 1;' })} />);
});
await act(async () => {
findButton(renderer, 'Run').props.onClick();
await Promise.resolve();
});
await act(async () => {
await findButton(renderer, 'Stop').props.onClick();
});
expect(messageApi.warning).toHaveBeenCalledWith('No running query to cancel.');
expect(messageApi.warning).not.toHaveBeenCalledWith('没有正在运行的查询可取消');
});
it('shows "Query canceled." in English when stop cancels a running query', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
backendApp.GenerateQueryID.mockResolvedValueOnce('query-1');
backendApp.DBQueryMulti.mockReturnValueOnce(new Promise(() => {}));
backendApp.CancelQuery.mockResolvedValueOnce({ success: true });
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'select 1;' })} />);
});
await act(async () => {
findButton(renderer, 'Run').props.onClick();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
await findButton(renderer, 'Stop').props.onClick();
});
expect(messageApi.success).toHaveBeenCalledWith('Query canceled.');
expect(messageApi.success).not.toHaveBeenCalledWith('查询已取消');
});
it('shows "Failed to cancel query" in English while preserving the raw error detail', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
backendApp.GenerateQueryID.mockResolvedValueOnce('query-1');
backendApp.DBQueryMulti.mockReturnValueOnce(new Promise(() => {}));
backendApp.CancelQuery.mockRejectedValueOnce(new Error('network down'));
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'select 1;' })} />);
});
await act(async () => {
findButton(renderer, 'Run').props.onClick();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
await findButton(renderer, 'Stop').props.onClick();
});
expect(messageApi.error).toHaveBeenCalledWith('Failed to cancel query: network down');
expect(messageApi.error).not.toHaveBeenCalledWith('取消查询失败: network down');
});
it('runs only appended SQL and keeps existing results after a full editor execution', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
})
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
editorState.value = 'select 1 as a;\nselect 2 as b;';
editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a');
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).toContain('结果 2');
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-count') && textContent(node) === '1';
})).toHaveLength(2);
});
it('replaces existing result tabs when rerunning the same formatted SQL', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [
{ columns: ['id'], rows: [{ id: 1 }, { id: 2 }, { id: 3 }] },
{ columns: ['id'], rows: Array.from({ length: 10 }, (_, index) => ({ id: index + 1 })) },
],
})
.mockResolvedValueOnce({
success: true,
data: [
{ columns: ['id'], rows: [{ id: 11 }, { id: 12 }, { id: 13 }] },
{ columns: ['id'], rows: Array.from({ length: 10 }, (_, index) => ({ id: index + 11 })) },
],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'SELECT * FROM fs_org_auth_application;\nSELECT * FROM fs_bcp_auth_info;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'SELECT * FROM fs_org_auth_application;'.length + 1 };
editorState.selection = null;
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).toContain('结果 2');
editorState.value = [
'SELECT',
' *',
'FROM',
' fs_org_auth_application;',
'',
'SELECT',
' *',
'FROM',
' fs_bcp_auth_info;',
].join('\n');
editorState.position = { lineNumber: 4, column: ' fs_org_auth_application;'.length + 1 };
editorState.selection = null;
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).toContain('结果 2');
expect(textContent(renderer!.toJSON())).not.toContain('结果 3');
expect(textContent(renderer!.toJSON())).not.toContain('结果 4');
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
})).toHaveLength(2);
});
it('keeps pinned result tabs across bulk close modes', () => {
const resultSets = [
{ key: 'result-1', pinned: true },
{ key: 'result-2' },
{ key: 'result-3', pinned: true },
{ key: 'result-4' },
] as any[];
expect(filterQueryEditorResultSetsForBulkClose(resultSets, 'result-2', 'other').map((result) => result.key))
.toEqual(['result-1', 'result-2', 'result-3']);
expect(filterQueryEditorResultSetsForBulkClose(resultSets, 'result-2', 'left').map((result) => result.key))
.toEqual(['result-1', 'result-2', 'result-3', 'result-4']);
expect(filterQueryEditorResultSetsForBulkClose(resultSets, 'result-2', 'right').map((result) => result.key))
.toEqual(['result-1', 'result-2', 'result-3']);
expect(filterQueryEditorResultSetsForBulkClose(resultSets, '', 'all').map((result) => result.key))
.toEqual(['result-1', 'result-3']);
});
it('pins a result from the context menu and keeps its snapshot when rerunning the same SQL', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({ success: true, data: [{ columns: ['value'], rows: [{ value: 'first' }] }] })
.mockResolvedValueOnce({ success: true, data: [{ columns: ['value'], rows: [{ value: 'second' }] }] });
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'select 1 as value;' })} />);
await Promise.resolve();
});
await act(async () => {
await findButton(renderer, '运行').props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const pinButton = renderer.root.findAll((node) =>
node.type === 'button' && textContent(node) === '固定结果',
)[0];
expect(pinButton).toBeTruthy();
await act(async () => {
pinButton.props.onClick();
});
expect(renderer.root.findAll((node) =>
String(node.props?.className || '').includes('query-result-tab-pin'),
)).toHaveLength(1);
const openInWindowButton = renderer.root.findAll((node) =>
node.type === 'button' && textContent(node) === '在独立窗口打开',
)[0];
await act(async () => {
await openInWindowButton.props.onClick();
await Promise.resolve();
});
expect(nativeDetachedWindowState.openNativeQueryResultWindow).toHaveBeenCalledWith(
expect.objectContaining({
result: expect.objectContaining({ pinned: true }),
}),
);
await act(async () => {
await findButton(renderer, '运行').props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const resultTabs = renderer.root.findAll((node) =>
node.type === 'button' && String(node.props?.['data-tab-key'] || '').startsWith('result-'),
);
expect(resultTabs).toHaveLength(2);
expect(dataGridState.latestProps?.data).toEqual([
expect.objectContaining({ value: 'second' }),
]);
const unpinButton = renderer.root.findAll((node) =>
node.type === 'button' && textContent(node) === '取消固定结果',
)[0];
expect(unpinButton).toBeTruthy();
});
it('provides context menu actions for query result tabs', async () => {
backendApp.DBQueryMulti.mockResolvedValue({
success: true,
data: [
{ columns: ['a'], rows: [{ a: 1 }] },
{ columns: ['b'], rows: [{ b: 2 }] },
{ columns: ['c'], rows: [{ c: 3 }] },
],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
})).toHaveLength(3);
await act(async () => {
renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭右侧')[1].props.onClick();
});
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
})).toHaveLength(2);
expect(textContent(renderer!.toJSON())).not.toContain('结果 3');
await act(async () => {
renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭左侧')[1].props.onClick();
});
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
})).toHaveLength(1);
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })]));
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })]));
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ c: 3 })]));
await act(async () => {
renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭所有')[0].props.onClick();
});
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
})).toHaveLength(0);
});
it('closes the active result tab directly without switching to the log tab', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ columns: ['a'], rows: [{ a: 1 }] },
{ columns: ['b'], rows: [{ b: 2 }] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;',
})} />);
});
await act(async () => {
const runButton = findButton(renderer, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const resultTabs = renderer.root.findAll((node) =>
node.type === 'button' && String(node.props?.['data-tab-key'] || '').startsWith('result-'),
);
expect(resultTabs).toHaveLength(2);
await act(async () => {
resultTabs[1].props.onClick();
});
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })]));
const closeButtons = renderer.root.findAll((node) =>
String(node.props?.className || '').split(/\s+/).includes('query-result-tab-close'),
);
await act(async () => {
closeButtons[1].props.onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
});
expect(renderer.root.findAll((node) =>
String(node.props?.className || '').split(/\s+/).includes('query-result-tab-label'),
)).toHaveLength(1);
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })]));
});
it('removes only the inline result inserted by a rolled-back native attach', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab()} />);
});
const restoreRegistrations = (window.addEventListener as any).mock.calls
.filter(([eventName]: [string]) => eventName === 'gonavi:restore-query-result');
const redetachRegistrations = (window.addEventListener as any).mock.calls
.filter(([eventName]: [string]) => eventName === 'gonavi:redetach-query-result');
expect(restoreRegistrations).toHaveLength(1);
expect(redetachRegistrations).toHaveLength(1);
await act(async () => {
restoreRegistrations[0][1](new CustomEvent('gonavi:restore-query-result', {
detail: {
windowId: 'query-result:tab-1:result-restored',
sourceQueryTabId: 'tab-1',
result: {
key: 'result-restored',
sql: 'select 1 as a',
columns: ['a'],
rows: [{ a: 1 }],
pkColumns: [],
readOnly: true,
},
},
}));
redetachRegistrations[0][1](new CustomEvent('gonavi:redetach-query-result', {
detail: {
windowId: 'query-result:tab-1:result-restored',
sourceQueryTabId: 'tab-1',
resultKey: 'result-restored',
},
}));
});
expect(renderer.root.findAll((node) =>
String(node.props?.className || '').split(/\s+/).includes('query-result-tab-label'),
)).toHaveLength(0);
await act(async () => {
restoreRegistrations[0][1](new CustomEvent('gonavi:restore-query-result', {
detail: {
sourceQueryTabId: 'tab-1',
result: {
key: 'result-existing',
sql: 'select existing',
columns: ['value'],
rows: [{ value: 'existing' }],
pkColumns: [],
readOnly: true,
pinned: true,
},
},
}));
restoreRegistrations[0][1](new CustomEvent('gonavi:restore-query-result', {
detail: {
windowId: 'query-result:tab-1:result-existing',
sourceQueryTabId: 'tab-1',
result: {
key: 'result-existing',
sql: 'select detached',
columns: ['value'],
rows: [{ value: 'detached' }],
pkColumns: [],
readOnly: true,
},
},
}));
redetachRegistrations[0][1](new CustomEvent('gonavi:redetach-query-result', {
detail: {
windowId: 'query-result:tab-1:result-existing',
sourceQueryTabId: 'tab-1',
resultKey: 'result-existing',
},
}));
});
expect(renderer.root.findAll((node) =>
String(node.props?.className || '').includes('query-result-tab-pin'),
)).toHaveLength(1);
expect(dataGridState.latestProps?.data).toEqual([
expect.objectContaining({ value: 'existing' }),
]);
await act(async () => {
renderer.unmount();
});
});
it('closes the final result and synchronously hides the log tab on the next command', async () => {
storeState.appearance.uiVersion = 'v2';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'select 1 as a;' })} />);
});
await act(async () => {
await findButton(renderer, '运行').props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const closeRegistrations = (window.addEventListener as any).mock.calls
.filter(([eventName]: [string]) => eventName === CLOSE_ACTIVE_RESULT_TAB_EVENT);
expect(closeRegistrations).toHaveLength(1);
const closeListener = closeRegistrations[0][1] as EventListener;
(window.dispatchEvent as any).mockImplementation((event: Event) => {
closeListener(event);
return true;
});
let firstOutcome!: CloseActiveResultShortcutRequest;
let secondOutcome!: CloseActiveResultShortcutRequest;
act(() => {
const firstRequest: CloseActiveResultShortcutRequest = { targetTabId: 'tab-1', handled: false, outcome: 'ignored' };
window.dispatchEvent(new CustomEvent(CLOSE_ACTIVE_RESULT_TAB_EVENT, { detail: firstRequest }));
firstOutcome = { ...firstRequest };
const secondRequest: CloseActiveResultShortcutRequest = { targetTabId: 'tab-1', handled: false, outcome: 'ignored' };
window.dispatchEvent(new CustomEvent(CLOSE_ACTIVE_RESULT_TAB_EVENT, { detail: secondRequest }));
secondOutcome = { ...secondRequest };
});
expect(firstOutcome).toEqual({ targetTabId: 'tab-1', handled: true, outcome: 'closed' });
expect(secondOutcome).toEqual({ targetTabId: 'tab-1', handled: true, outcome: 'hidden' });
expect(renderer.root.findAll((node) =>
node.props?.['data-gonavi-close-shortcut-scope'] === 'result',
)).toHaveLength(0);
await act(async () => {
renderer.unmount();
});
});
it('ignores result close commands for hidden, invalid, or inactive result targets', async () => {
storeState.appearance.uiVersion = 'v2';
let hiddenRenderer!: ReactTestRenderer;
await act(async () => {
hiddenRenderer = create(<QueryEditor tab={createTab()} />);
});
const closeRegistrations = (window.addEventListener as any).mock.calls
.filter(([eventName]: [string]) => eventName === CLOSE_ACTIVE_RESULT_TAB_EVENT);
expect(closeRegistrations).toHaveLength(1);
const hiddenRequest: CloseActiveResultShortcutRequest = { targetTabId: 'tab-1', handled: false, outcome: 'ignored' };
closeRegistrations[0][1](new CustomEvent(CLOSE_ACTIVE_RESULT_TAB_EVENT, { detail: hiddenRequest }));
expect(hiddenRequest).toEqual({ targetTabId: 'tab-1', handled: true, outcome: 'ignored' });
const detachedRequest: CloseActiveResultShortcutRequest = { targetTabId: 'detached-tab', handled: false, outcome: 'ignored' };
closeRegistrations[0][1](new CustomEvent(CLOSE_ACTIVE_RESULT_TAB_EVENT, { detail: detachedRequest }));
expect(detachedRequest).toEqual({ targetTabId: 'detached-tab', handled: false, outcome: 'ignored' });
await act(async () => {
hiddenRenderer.unmount();
});
vi.mocked(window.addEventListener).mockClear();
storeState.appearance.uiVersion = 'legacy';
let invalidRenderer!: ReactTestRenderer;
await act(async () => {
invalidRenderer = create(<QueryEditor tab={createTab({ id: 'tab-invalid', resultPanelVisible: true })} />);
});
const invalidRegistrations = (window.addEventListener as any).mock.calls
.filter(([eventName]: [string]) => eventName === CLOSE_ACTIVE_RESULT_TAB_EVENT);
expect(invalidRegistrations).toHaveLength(1);
const invalidRequest: CloseActiveResultShortcutRequest = { targetTabId: 'tab-invalid', handled: false, outcome: 'ignored' };
invalidRegistrations[0][1](new CustomEvent(CLOSE_ACTIVE_RESULT_TAB_EVENT, { detail: invalidRequest }));
expect(invalidRequest).toEqual({ targetTabId: 'tab-invalid', handled: true, outcome: 'ignored' });
await act(async () => {
invalidRenderer.unmount();
});
vi.mocked(window.addEventListener).mockClear();
let inactiveRenderer!: ReactTestRenderer;
await act(async () => {
inactiveRenderer = create(<QueryEditor tab={createTab({ id: 'tab-2' })} isActive={false} />);
});
expect((window.addEventListener as any).mock.calls
.filter(([eventName]: [string]) => eventName === CLOSE_ACTIVE_RESULT_TAB_EVENT)).toHaveLength(0);
await act(async () => {
inactiveRenderer.unmount();
});
});
it('replaces the current result when rerunning the same cursor SQL', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
})
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 10 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
editorState.selection = {
startLineNumber: 1,
startColumn: 'select 1 as a;'.length + 1,
endLineNumber: 1,
endColumn: 'select 1 as a;'.length + 1,
positionLineNumber: 1,
positionColumn: 'select 1 as a;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const tabLabels = renderer!.root.findAll((node) => textContent(node).includes('结果 '));
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).not.toContain('结果 2');
expect(tabLabels.length).toBeGreaterThan(0);
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 10 })]));
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 2 as b');
});
it('appends a result when running a different cursor SQL after an existing result', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
})
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
editorState.selection = {
startLineNumber: 1,
startColumn: 'select 1 as a;'.length + 1,
endLineNumber: 1,
endColumn: 'select 1 as a;'.length + 1,
positionLineNumber: 1,
positionColumn: 'select 1 as a;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 };
editorState.selection = {
startLineNumber: 2,
startColumn: 'select 2 as b;'.length + 1,
endLineNumber: 2,
endColumn: 'select 2 as b;'.length + 1,
positionLineNumber: 2,
positionColumn: 'select 2 as b;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 3 as c');
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).toContain('结果 2');
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })]));
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })]));
});
it('renders compact result tab labels with row counts outside the title text', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ columns: ['a'], rows: [{ a: 1 }, { a: 2 }] },
{ columns: ['b'], rows: [{ b: 3 }] },
],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;',
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const tabLabels = renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
});
const counts = renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-count');
});
const titles = renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-text');
});
expect(tabLabels).toHaveLength(2);
expect(titles.map((node) => textContent(node))).toEqual(['结果 1', '结果 2']);
expect(counts.map((node) => textContent(node))).toEqual(['2', '1']);
expect(textContent(renderer!.toJSON())).not.toContain('结果 1 (2)');
});
it('keeps query result tabs flush, full-height, and readable in v2 UI', () => {
const source = readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8');
const css = readV2ThemeCss();
const resultNavCss = css.slice(
css.indexOf('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav {'),
css.indexOf('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-extra-content {'),
);
const resultTabCss = css.slice(
css.indexOf('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab {'),
css.indexOf('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-nav-list {'),
);
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab {');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab-btn {');
expect(resultNavCss).toContain('padding: 0 8px 0 0;');
expect(resultNavCss).toContain('min-height: 46px;');
expect(resultTabCss).toContain('height: 46px !important;');
expect(resultTabCss).toContain('margin: 0 !important;');
expect(resultTabCss).toContain('border-radius: 8px !important;');
expect(css).toContain([
'body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-nav-wrap,',
'body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-nav-list {',
' min-height: 46px;',
'}',
].join('\n'));
expect(css).toContain('user-select: none;');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tab-text {');
});
it('connects each query result sort state and callback to DataGrid', async () => {
const onResultSort = vi.fn();
const sortInfo = [{ columnKey: 'name', order: 'ascend', enabled: true }];
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<QueryEditorResultsPanel
resultSets={[{
key: 'result-1',
sql: 'select id, name from users',
rows: [{ id: 1, name: 'Ada' }],
columns: ['id', 'name'],
pkColumns: [],
readOnly: true,
sortInfo,
}]}
activeResultKey="result-1"
isActive
loading={false}
executionError=""
sqlLogCount={0}
darkMode={false}
isV2Ui
currentDb="main"
currentConnectionId="conn-1"
toggleShortcutLabel=""
onActiveResultKeyChange={vi.fn()}
onHide={vi.fn()}
onCloseResult={vi.fn()}
onCloseOtherResultTabs={vi.fn()}
onCloseResultTabsToLeft={vi.fn()}
onCloseResultTabsToRight={vi.fn()}
onCloseAllResultTabs={vi.fn()}
onResultPinnedChange={vi.fn()}
onReloadResult={vi.fn()}
onResultPageChange={vi.fn()}
onResultSort={onResultSort}
onDiagnoseExecutionError={vi.fn()}
/>,
);
});
expect(dataGridState.latestProps?.sortInfoExternal).toEqual(sortInfo);
expect(dataGridState.latestProps?.onSort).toEqual(expect.any(Function));
const serialized = JSON.stringify([{ columnKey: 'id', order: 'descend', enabled: true }]);
dataGridState.latestProps.onSort(serialized, '');
expect(onResultSort).toHaveBeenCalledWith('result-1', serialized, '');
renderer.unmount();
});
it('activates shortcuts only for the visible result grid in the active query editor', async () => {
const resultSets = [
{
key: 'result-1',
sql: 'select 1 as value',
rows: [{ value: 1 }],
columns: ['value'],
pkColumns: [],
readOnly: true,
},
{
key: 'result-2',
sql: 'select 2 as value',
rows: [{ value: 2 }],
columns: ['value'],
pkColumns: [],
readOnly: true,
},
];
const renderPanel = (activeResultKey: string, isActive: boolean) => (
<QueryEditorResultsPanel
resultSets={resultSets}
activeResultKey={activeResultKey}
isActive={isActive}
loading={false}
executionError=""
sqlLogCount={0}
darkMode={false}
isV2Ui
currentDb="main"
currentConnectionId="conn-1"
toggleShortcutLabel=""
onActiveResultKeyChange={vi.fn()}
onHide={vi.fn()}
onCloseResult={vi.fn()}
onCloseOtherResultTabs={vi.fn()}
onCloseResultTabsToLeft={vi.fn()}
onCloseResultTabsToRight={vi.fn()}
onCloseAllResultTabs={vi.fn()}
onResultPinnedChange={vi.fn()}
onReloadResult={vi.fn()}
onResultPageChange={vi.fn()}
onResultSort={vi.fn()}
onDiagnoseExecutionError={vi.fn()}
/>
);
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(renderPanel('result-1', true));
});
expect(dataGridState.latestProps?.data).toEqual([{ value: 1 }]);
expect(dataGridState.latestProps?.isActive).toBe(true);
await act(async () => {
renderer.update(renderPanel('result-1', false));
});
expect(dataGridState.latestProps?.isActive).toBe(false);
await act(async () => {
renderer.update(renderPanel('result-2', true));
});
expect(dataGridState.latestProps?.data).toEqual([{ value: 2 }]);
expect(dataGridState.latestProps?.isActive).toBe(true);
expect(readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8'))
.toContain('isActive={isActive && resolvedActiveResultKey === rs.key}');
renderer.unmount();
});
it('sorts complete query results locally and restores execution order when cleared', async () => {
const query = "select 3 as id, 'Zulu' as name union all select 1, 'Alpha' union all select 2, 'Alpha';";
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: ['id', 'name'],
rows: [
{ id: 3, name: 'Zulu' },
{ id: 1, name: 'Alpha' },
{ id: 2, name: 'Alpha' },
],
}],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query })} />);
});
await act(async () => {
await findButton(renderer, '运行').props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.data.map((row: any) => row.name)).toEqual(['Zulu', 'Alpha', 'Alpha']);
expect(dataGridState.latestProps?.sortInfoExternal).toEqual([]);
await act(async () => {
await dataGridState.latestProps.onSort(JSON.stringify([
{ columnKey: 'name', order: 'ascend', enabled: true },
{ columnKey: 'id', order: 'descend', enabled: true },
]), '');
});
expect(dataGridState.latestProps?.data.map((row: any) => row.name)).toEqual(['Alpha', 'Alpha', 'Zulu']);
expect(dataGridState.latestProps?.data.map((row: any) => row.__gonavi_row_key__)).toEqual([2, 1, 0]);
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1);
await act(async () => {
await dataGridState.latestProps.onSort('[]', '');
});
expect(dataGridState.latestProps?.data.map((row: any) => row.name)).toEqual(['Zulu', 'Alpha', 'Alpha']);
expect(dataGridState.latestProps?.data.map((row: any) => row.__gonavi_row_key__)).toEqual([0, 1, 2]);
expect(dataGridState.latestProps?.sortInfoExternal).toEqual([]);
renderer.unmount();
});
it('requeries the first page with outer ordering when a pageable result is sorted', async () => {
storeState.queryOptions.maxRows = 2;
const query = 'select id, name from (select id, name from users) q;';
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{
columns: ['id', 'name'],
rows: [{ id: 2, name: 'Beta' }, { id: 1, name: 'Alpha' }],
}],
})
.mockResolvedValueOnce({
success: true,
data: [{
columns: ['id', 'name'],
rows: [{ id: 4, name: 'Delta' }, { id: 3, name: 'Charlie' }],
}],
})
.mockResolvedValueOnce({
success: true,
data: [{
columns: ['id', 'name'],
rows: [
{ id: 1, name: 'Alpha' },
{ id: 2, name: 'Beta' },
{ id: 3, name: 'Charlie' },
],
}],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query })} />);
});
await act(async () => {
await findButton(renderer, '运行').props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.pagination).toMatchObject({ current: 1, pageSize: 2 });
await act(async () => {
await dataGridState.latestProps.onPageChange(2, 2);
});
expect(dataGridState.latestProps?.pagination).toMatchObject({ current: 2, pageSize: 2 });
await act(async () => {
await dataGridState.latestProps.onSort(JSON.stringify([
{ columnKey: 'name', order: 'ascend', enabled: true },
]), '');
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(3);
const sortedPageSql = String(backendApp.DBQueryMulti.mock.calls[2][2]);
expect(sortedPageSql).toContain('AS __gonavi_query_page__ ORDER BY `name` ASC LIMIT 3 OFFSET 0');
expect(dataGridState.latestProps?.pagination).toMatchObject({ current: 1, pageSize: 2 });
expect(dataGridState.latestProps?.sortInfoExternal).toEqual([
{ columnKey: 'name', order: 'ascend', enabled: true },
]);
expect(dataGridState.latestProps?.data.map((row: any) => row.name)).toEqual(['Alpha', 'Beta']);
renderer.unmount();
});
it('does not render the embedded sql execution log tab in legacy UI', () => {
const renderResultsPanel = (isV2Ui: boolean, sqlLogCount = 1) => create(
<QueryEditorResultsPanel
resultSets={[]}
activeResultKey=""
isActive
loading={false}
executionError=""
sqlLogCount={sqlLogCount}
darkMode={false}
isV2Ui={isV2Ui}
currentDb="main"
currentConnectionId="conn-1"
toggleShortcutLabel=""
onActiveResultKeyChange={vi.fn()}
onHide={vi.fn()}
onCloseResult={vi.fn()}
onCloseOtherResultTabs={vi.fn()}
onCloseResultTabsToLeft={vi.fn()}
onCloseResultTabsToRight={vi.fn()}
onCloseAllResultTabs={vi.fn()}
onResultPinnedChange={vi.fn()}
onReloadResult={vi.fn()}
onResultPageChange={vi.fn()}
onResultSort={vi.fn()}
onDiagnoseExecutionError={vi.fn()}
/>,
);
const legacyRenderer = renderResultsPanel(false);
expect(legacyRenderer.root.findAll((node) => node.props?.['data-log-panel'] === 'true')).toHaveLength(0);
expect(legacyRenderer.root.findAll((node) => node.props?.['data-tab-key'] === '__gonavi_sql_execution_log__')).toHaveLength(0);
legacyRenderer.unmount();
const v2Renderer = renderResultsPanel(true);
expect(v2Renderer.root.findAll((node) => node.props?.['data-log-panel'] === 'true')).toHaveLength(1);
expect(v2Renderer.root.findAll((node) => node.props?.['data-tab-key'] === '__gonavi_sql_execution_log__')).toHaveLength(1);
const tabActions = v2Renderer.root.findByProps({ className: 'query-result-panel-tab-actions' });
const actionButtons = tabActions.findAll((node) => node.type === 'button');
const resultPanelStyles = v2Renderer.root.findAll((node) => node.type === 'style')
.map((node) => textContent(node))
.join('\n');
expect(actionButtons.map((node) => node.props.className)).toEqual([
'query-result-panel-clear query-result-panel-tab-action',
'query-result-panel-hide query-result-panel-tab-action',
]);
expect(resultPanelStyles).toContain(
'.query-result-panel-tab-actions { display: inline-flex; flex-direction: row;',
);
expect(resultPanelStyles).toContain(
'.query-result-tabs .ant-tabs-extra-content .query-result-panel-tab-action { width: 28px; min-width: 28px; height: 28px !important; min-height: 28px !important; padding: 0 !important;',
);
act(() => {
actionButtons[0].props.onClick();
});
expect(storeState.clearSqlLogs).toHaveBeenCalledTimes(1);
v2Renderer.unmount();
const emptyV2Renderer = renderResultsPanel(true, 0);
expect(emptyV2Renderer.root.findAll((node) => node.props?.['data-log-panel'] === 'true')).toHaveLength(1);
expect(emptyV2Renderer.root.findAll((node) => node.props?.['data-tab-key'] === QUERY_EDITOR_SQL_LOG_TAB_KEY)).toHaveLength(1);
expect(emptyV2Renderer.root.findAll((node) =>
node.props?.['data-gonavi-close-shortcut-scope'] === 'result',
)).toHaveLength(1);
emptyV2Renderer.unmount();
});
it('uses the shared effective result key for stale-key rendering fallbacks', () => {
const resultSets = [{
key: 'result-1',
sql: 'select 1 as value',
rows: [{ value: 1 }],
columns: ['value'],
pkColumns: [],
readOnly: true,
}];
expect(resolveEffectiveActiveResultKey(resultSets, 'stale-result', true)).toBe('result-1');
expect(resolveEffectiveActiveResultKey([], 'stale-result', true)).toBe(QUERY_EDITOR_SQL_LOG_TAB_KEY);
expect(resolveEffectiveActiveResultKey([], 'stale-result', false)).toBe('');
const renderer = create(
<QueryEditorResultsPanel
resultSets={resultSets}
activeResultKey="stale-result"
isActive
loading={false}
executionError=""
sqlLogCount={0}
darkMode={false}
isV2Ui
currentDb="main"
currentConnectionId="conn-1"
toggleShortcutLabel=""
onActiveResultKeyChange={vi.fn()}
onHide={vi.fn()}
onCloseResult={vi.fn()}
onCloseOtherResultTabs={vi.fn()}
onCloseResultTabsToLeft={vi.fn()}
onCloseResultTabsToRight={vi.fn()}
onCloseAllResultTabs={vi.fn()}
onResultPinnedChange={vi.fn()}
onReloadResult={vi.fn()}
onResultPageChange={vi.fn()}
onResultSort={vi.fn()}
onDiagnoseExecutionError={vi.fn()}
/>,
);
expect(dataGridState.latestProps?.data).toEqual([{ value: 1 }]);
renderer.unmount();
});
it('keeps the v2 query editor toolbar grouped and compact', () => {
const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8');
const css = readV2ThemeCss();
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-selects');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-actions');
expect(css).toContain('width: 48px !important;');
expect(css).toContain('flex: 0 0 48px !important;');
expect(css).toContain('flex: 0 0 auto !important;');
expect(css).toContain('justify-content: flex-start;');
expect(css).toContain('height: 32px !important;');
expect(css).toContain('line-height: 30px !important;');
expect(css).toContain('display: inline-flex !important;');
expect(css).toContain('gap: 6px;');
expect(css).toContain('overflow-x: auto;');
expect(css).toContain('overflow-y: hidden;');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-action-pair');
expect(css).toContain('gap: 8px;');
expect(css).toContain('margin-left: 0 !important;');
expect(css).toContain('max-width: 760px;');
expect(css).toContain('width: 140px !important;');
expect(css).toContain('width: 166px !important;');
expect(css).toContain('width: 132px !important;');
expect(css).toContain('width: 34px !important;');
expect(css).toContain('@media (max-width: 900px)');
expect(css).not.toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-transaction-row {');
const queryToolbarMainCss = css.slice(css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-main {'), css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-selects {'));
expect(queryToolbarMainCss).toContain('flex-wrap: nowrap;');
expect(queryToolbarMainCss).toContain('width: max-content;');
expect(queryToolbarMainCss).not.toContain('flex-wrap: wrap;');
expect(queryToolbarMainCss).not.toContain('margin-left: auto;');
expect(queryToolbarMainCss).not.toContain('justify-content: flex-end;');
});
it('coalesces editor result splitter dragging through requestAnimationFrame', async () => {
const moveListeners: Array<(event: MouseEvent) => void> = [];
const upListeners: Array<() => void> = [];
const frameCallbacks: FrameRequestCallback[] = [];
vi.mocked(document.addEventListener).mockImplementation((type: string, listener: any) => {
if (type === 'mousemove') moveListeners.push(listener);
if (type === 'mouseup') upListeners.push(listener);
});
vi.mocked(window.requestAnimationFrame).mockImplementation((callback: FrameRequestCallback) => {
frameCallbacks.push(callback);
return frameCallbacks.length;
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ resultPanelVisible: true })} />);
});
vi.mocked(window.requestAnimationFrame).mockClear();
frameCallbacks.length = 0;
const resizer = renderer.root.find((node) => node.props?.title === '拖动调整高度');
await act(async () => {
resizer.props.onMouseDown({ clientY: 300, preventDefault: vi.fn() });
moveListeners.forEach((listener) => listener({ clientY: 340 } as MouseEvent));
moveListeners.forEach((listener) => listener({ clientY: 380 } as MouseEvent));
});
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
await act(async () => {
frameCallbacks.splice(0).forEach((callback) => callback(16));
});
await act(async () => {
upListeners.forEach((listener) => listener());
});
expect(document.removeEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function));
expect(document.removeEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function));
});
it('prevents Monaco native drag marker and keeps metadata hover after sidebar object drops', async () => {
const domListeners: Record<string, ((event?: any) => void)[]> = {};
editorState.domNode = {
style: { cursor: '' },
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
domListeners[type] ||= [];
domListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
} as any;
editorState.editor.getTargetAtClientPoint = vi.fn(() => ({
position: { lineNumber: 1, column: 'SELECT * FROM '.length + 1 },
}));
editorState.value = 'SELECT * FROM ';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'front_end_sys' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_front_end_sys: 'fs_mkefu_regist_record' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'front_end_sys' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const dragOverEvent = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
types: ['application/x-gonavi-sql-object', 'text/plain'],
dropEffect: 'none',
getData: vi.fn(() => ''),
},
};
await act(async () => {
domListeners.dragover?.forEach((listener) => listener(dragOverEvent));
});
expect(dragOverEvent.preventDefault).toHaveBeenCalled();
expect(dragOverEvent.stopPropagation).toHaveBeenCalled();
expect(dragOverEvent.dataTransfer.dropEffect).toBe('copy');
expect(dragOverEvent.dataTransfer.getData).not.toHaveBeenCalled();
await act(async () => {
domListeners.drop?.forEach((listener) => listener({
clientX: 10,
clientY: 10,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
types: ['application/x-gonavi-sql-object', 'text/plain'],
getData: (type: string) => {
if (type === 'application/x-gonavi-sql-object') {
return JSON.stringify({ text: 'fs_mkefu_regist_record' });
}
if (type === 'text/plain') {
return 'fs_mkefu_regist_record';
}
return '';
},
},
}));
});
const hover = editorState.hoverProviders[0]?.provideHover(
editorState.editor.getModel(),
{ lineNumber: 1, column: 'SELECT * FROM fs_mkefu_regist_record'.length },
);
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber: 1, column: 'SELECT * FROM fs_mkefu_regist_record'.length } },
event: {
leftButton: true,
ctrlKey: true,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
},
});
});
expect(storeState.setActiveContext).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
connectionId: 'conn-1',
dbName: 'front_end_sys',
tableName: 'fs_mkefu_regist_record',
objectType: 'table',
}));
});
it('fetches database and completion metadata only for the active query tab', async () => {
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValue({
success: true,
data: [{ Database: 'main' }],
});
backendApp.DBGetTables.mockResolvedValue({
success: true,
data: [{ Tables_in_main: 'users' }],
});
const firstTab = createTab({ id: 'tab-1', query: 'SELECT * FROM users' });
const secondTab = createTab({ id: 'tab-2', query: 'SELECT * FROM orders' });
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<>
<QueryEditor key={firstTab.id} tab={firstTab} isActive />
<QueryEditor key={secondTab.id} tab={secondTab} isActive={false} />
</>,
);
});
await vi.waitFor(() => {
expect(backendApp.DBGetDatabases).toHaveBeenCalledTimes(1);
expect(backendApp.DBGetTables).toHaveBeenCalledTimes(1);
expect(backendApp.DBGetAllColumns).toHaveBeenCalledTimes(1);
});
await act(async () => {
renderer.update(
<>
<QueryEditor key={firstTab.id} tab={firstTab} isActive={false} />
<QueryEditor key={secondTab.id} tab={secondTab} isActive />
</>,
);
});
await vi.waitFor(() => {
expect(backendApp.DBGetDatabases).toHaveBeenCalledTimes(2);
expect(backendApp.DBGetTables).toHaveBeenCalledTimes(2);
expect(backendApp.DBGetAllColumns).toHaveBeenCalledTimes(2);
});
await act(async () => {
renderer.unmount();
});
});
it('does not rerender inactive query editors when SQL logs change', async () => {
const renderCounts = { active: 0, inactive: 0 };
const firstTab = createTab({ id: 'tab-1', query: 'SELECT * FROM users' });
const secondTab = createTab({ id: 'tab-2', query: 'SELECT * FROM orders' });
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<>
<React.Profiler id="active" onRender={() => { renderCounts.active += 1; }}>
<QueryEditor key={firstTab.id} tab={firstTab} isActive />
</React.Profiler>
<React.Profiler id="inactive" onRender={() => { renderCounts.inactive += 1; }}>
<QueryEditor key={secondTab.id} tab={secondTab} isActive={false} />
</React.Profiler>
</>,
);
});
const baseline = { ...renderCounts };
await act(async () => {
storeState.sqlLogs = [{
id: 'log-1',
timestamp: Date.now(),
sql: 'SELECT 1',
status: 'success',
duration: 1,
}];
notifyStoreSubscribers();
});
expect(renderCounts.active).toBeGreaterThan(baseline.active);
expect(renderCounts.inactive).toBe(baseline.inactive);
await act(async () => {
renderer.update(
<>
<React.Profiler id="active" onRender={() => { renderCounts.active += 1; }}>
<QueryEditor key={firstTab.id} tab={firstTab} isActive={false} />
</React.Profiler>
<React.Profiler id="inactive" onRender={() => { renderCounts.inactive += 1; }}>
<QueryEditor key={secondTab.id} tab={secondTab} isActive />
</React.Profiler>
</>,
);
});
const switchedBaseline = { ...renderCounts };
await act(async () => {
storeState.sqlLogs = [{
id: 'log-2',
timestamp: Date.now() + 1,
sql: 'SELECT 2',
status: 'success',
duration: 1,
}, ...storeState.sqlLogs];
notifyStoreSubscribers();
});
expect(renderCounts.active).toBe(switchedBaseline.active);
expect(renderCounts.inactive).toBeGreaterThan(switchedBaseline.inactive);
await act(async () => {
renderer.unmount();
});
});
it('keeps object hyperlink tab opening tied to the dragged database after drop', async () => {
const domListeners: Record<string, ((event?: any) => void)[]> = {};
editorState.domNode = {
style: { cursor: '' },
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
domListeners[type] ||= [];
domListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
} as any;
editorState.editor.getTargetAtClientPoint = vi.fn(() => ({
position: { lineNumber: 1, column: 'SELECT * FROM '.length + 1 },
}));
editorState.value = 'SELECT * FROM ';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'front_end_sys' }] });
backendApp.DBGetTables
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] })
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_front_end_sys: 'fs_mkefu_regist_record' }] });
backendApp.DBGetAllColumns
.mockResolvedValueOnce({ success: true, data: [] })
.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
domListeners.drop?.forEach((listener) => listener({
clientX: 10,
clientY: 10,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
types: ['application/x-gonavi-sql-object', 'text/plain'],
getData: (type: string) => {
if (type === 'application/x-gonavi-sql-object') {
return JSON.stringify({
text: 'fs_mkefu_regist_record',
nodeType: 'table',
connectionId: 'conn-1',
dbName: 'front_end_sys',
});
}
if (type === 'text/plain') {
return 'fs_mkefu_regist_record';
}
return '';
},
},
}));
});
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber: 1, column: 'SELECT * FROM front_end_sys.fs_mkefu_regist_record'.length } },
event: {
leftButton: true,
ctrlKey: true,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
},
});
});
expect(storeState.setActiveContext).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
connectionId: 'conn-1',
dbName: 'front_end_sys',
tableName: 'fs_mkefu_regist_record',
objectType: 'table',
}));
});
it('runs selected SQL before cursor SQL', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['selected'], rows: [{ selected: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as selected;\nselect 3;',
})} />);
});
editorState.position = { lineNumber: 1, column: 4 };
editorState.selection = {
startLineNumber: 2,
startColumn: 1,
endLineNumber: 2,
endColumn: 'select 2 as selected'.length + 1,
};
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as selected'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('allows editable table columns while leaving expression columns out of commits', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: ['DISPLAY_NAME', 'NAME_UPPER', '__gonavi_locator_1_ID'],
rows: [{ DISPLAY_NAME: 'old-name', NAME_UPPER: 'OLD-NAME', __gonavi_locator_1_ID: 7 }],
}],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'SELECT NAME AS DISPLAY_NAME, UPPER(NAME) AS NAME_UPPER FROM users',
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['__gonavi_locator_1_ID'],
hiddenColumns: ['__gonavi_locator_1_ID'],
writableColumns: {
DISPLAY_NAME: 'NAME',
},
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('`ID` AS `__gonavi_locator_1_ID`');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('keeps DuckDB qualified table query results writable when primary key metadata arrives', async () => {
storeState.connections[0].config.type = 'duckdb';
storeState.connections[0].config.database = 'main';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_id'], rows: [{ NAME: 'launch', __gonavi_locator_1_id: 7 }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'id', key: 'PRI' }, { name: 'name', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM main.events' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'main', 'main.events');
expect(dataGridState.latestProps?.tableName).toBe('main.events');
expect(dataGridState.latestProps?.pkColumns).toEqual(['id']);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['id'],
valueColumns: ['__gonavi_locator_1_id'],
hiddenColumns: ['__gonavi_locator_1_id'],
writableColumns: {
NAME: 'name',
},
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"id" AS "__gonavi_locator_1_id"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('uses hidden DuckDB rowid when query results have no primary or unique key', async () => {
storeState.connections[0].config.type = 'duckdb';
storeState.connections[0].config.database = 'main';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_duckdb_rowid__'], rows: [{ NAME: 'launch', __gonavi_duckdb_rowid__: 17 }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'name', key: '' }],
});
backendApp.DBGetIndexes.mockResolvedValueOnce({
success: true,
data: [],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM main.events' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('main.events');
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'duckdb-rowid',
columns: ['rowid'],
valueColumns: ['__gonavi_duckdb_rowid__'],
hiddenColumns: ['__gonavi_duckdb_rowid__'],
writableColumns: {
NAME: 'name',
},
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('rowid AS "__gonavi_duckdb_rowid__"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('auto aliases Oracle duplicate explicit columns before alias star expansion', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'APP';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: ['EHR_USERID_1', 'USERID', 'EHR_USERID', 'USERNAME'],
rows: [{
EHR_USERID_1: 'emp-1',
USERID: 7,
EHR_USERID: 'emp-1',
USERNAME: 'alice',
}],
}],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [
{ name: 'USERID', key: 'PRI' },
{ name: 'EHR_USERID', key: '' },
{ name: 'USERNAME', key: '' },
],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'APP',
query: 'SELECT EHR_USERID, a.* FROM S_USER_BASE a',
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['USERID'],
valueColumns: ['USERID'],
writableColumns: {
USERID: 'USERID',
EHR_USERID: 'EHR_USERID',
USERNAME: 'USERNAME',
},
readOnly: false,
});
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('EHR_USERID AS EHR_USERID_1, a.*');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('keeps a multiline single-table result tied to its editable all-columns locator', async () => {
const sql = [
'SELECT a.COMPID, a.MEMCARDNO,',
' a.MODIFYUSER, a.MODIFYTIME',
'FROM D_MEMBER_CARDTYPE_MODFIY_LOG a',
].join('\n');
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: ['COMPID', 'MEMCARDNO', 'MODIFYUSER', 'MODIFYTIME'],
rows: [{ COMPID: 1, MEMCARDNO: 'M-1', MODIFYUSER: 'admin', MODIFYTIME: '2026-07-10' }],
}],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [
{ name: 'COMPID', key: '' },
{ name: 'MEMCARDNO', key: '' },
{ name: 'MODIFYUSER', key: '' },
{ name: 'MODIFYTIME', key: '' },
],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: sql })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('D_MEMBER_CARDTYPE_MODFIY_LOG');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'all-columns',
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(dataGridState.latestProps?.columnPinScope).toBeUndefined();
renderer!.unmount();
});
it.each([
'mysql',
'mariadb',
'oceanbase',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'opengauss',
'gaussdb',
'sqlserver',
'sqlite',
'duckdb',
'oracle',
'dameng',
'tdengine',
'clickhouse',
])(
'keeps aggregate query results silently read-only for %s',
async (dbType) => {
storeState.connections[0].config.type = dbType;
storeState.connections[0].config.database = dbType === 'oracle' || dbType === 'dameng' ? 'APP' : 'main';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['COUNT'], rows: [{ COUNT: 1 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: storeState.connections[0].config.database,
query: 'SELECT count(1) FROM users',
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const expectedTableName = dbType === 'oracle' || dbType === 'dameng' ? 'USERS' : 'users';
expect(dataGridState.latestProps?.tableName).toBe(expectedTableName);
expect(dataGridState.latestProps?.editLocator).toBeUndefined();
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(backendApp.DBGetColumns).not.toHaveBeenCalled();
expect(backendApp.DBGetIndexes).not.toHaveBeenCalled();
expect(messageApi.warning).not.toHaveBeenCalled();
},
);
});
type ResultTabTestListener = (event: Record<string, unknown>) => void;
const createResultTabTestEventTarget = () => {
const listeners = new Map<string, Set<ResultTabTestListener>>();
return {
addEventListener: vi.fn((type: string, listener: ResultTabTestListener) => {
const registered = listeners.get(type) ?? new Set<ResultTabTestListener>();
registered.add(listener);
listeners.set(type, registered);
}),
removeEventListener: vi.fn((type: string, listener: ResultTabTestListener) => {
listeners.get(type)?.delete(listener);
}),
dispatch(type: string, event: Record<string, unknown> = {}) {
for (const listener of [...(listeners.get(type) ?? [])]) {
listener({ type, ...event });
}
},
listenerCount(type: string) {
return listeners.get(type)?.size ?? 0;
},
};
};
const createResultTabPointerCaptureTarget = (throwOnRelease = false) => {
const eventTarget = createResultTabTestEventTarget();
const capturedPointers = new Set<number>();
return Object.assign(eventTarget, {
setPointerCapture: vi.fn((pointerId: number) => {
capturedPointers.add(pointerId);
}),
hasPointerCapture: vi.fn((pointerId: number) => capturedPointers.has(pointerId)),
releasePointerCapture: vi.fn((pointerId: number) => {
if (throwOnRelease) throw new Error('capture already released');
capturedPointers.delete(pointerId);
}),
});
};
describe('QueryEditorResultsPanel result-tab detach lifecycle', () => {
let renderer: ReactTestRenderer | null = null;
let windowTarget: ReturnType<typeof createResultTabTestEventTarget> & Record<string, any>;
let documentTarget: Record<string, any>;
let classNames: Set<string>;
let removeAllRanges: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
classNames = new Set<string>();
removeAllRanges = vi.fn();
windowTarget = Object.assign(createResultTabTestEventTarget(), {
screenX: 0,
screenY: 0,
outerWidth: 1200,
outerHeight: 800,
innerWidth: 1200,
innerHeight: 800,
getSelection: vi.fn(() => ({ rangeCount: 1, removeAllRanges })),
});
documentTarget = {
body: {
style: {
userSelect: 'text',
webkitUserSelect: 'auto',
},
},
documentElement: {
classList: {
add: vi.fn((className: string) => classNames.add(className)),
remove: vi.fn((className: string) => classNames.delete(className)),
contains: (className: string) => classNames.has(className),
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
vi.stubGlobal('window', windowTarget);
vi.stubGlobal('document', documentTarget);
});
afterEach(() => {
act(() => {
renderer?.unmount();
});
renderer = null;
vi.unstubAllGlobals();
vi.clearAllMocks();
});
const renderDetachableResultPanel = async (onOpenResultInWindow = vi.fn()) => {
await act(async () => {
renderer = create(
<QueryEditorResultsPanel
resultSets={[{
key: 'result-1',
sql: 'select 1',
rows: [{ value: 1 }],
columns: ['value'],
pkColumns: [],
readOnly: true,
}]}
activeResultKey="result-1"
isActive
loading={false}
executionError=""
sqlLogCount={0}
darkMode={false}
isV2Ui
currentDb="main"
currentConnectionId="conn-1"
toggleShortcutLabel=""
onActiveResultKeyChange={vi.fn()}
onHide={vi.fn()}
onCloseResult={vi.fn()}
onCloseOtherResultTabs={vi.fn()}
onCloseResultTabsToLeft={vi.fn()}
onCloseResultTabsToRight={vi.fn()}
onCloseAllResultTabs={vi.fn()}
onResultPinnedChange={vi.fn()}
onOpenResultInWindow={onOpenResultInWindow}
onReloadResult={vi.fn()}
onResultPageChange={vi.fn()}
onResultSort={vi.fn()}
onDiagnoseExecutionError={vi.fn()}
/>,
);
});
};
const beginResultTabDrag = (captureTarget = createResultTabPointerCaptureTarget()) => {
const resultTabLabel = renderer!.root.findAll((node) =>
typeof node.props?.onPointerDown === 'function'
&& String(node.props?.className || '').split(/\s+/).includes('query-result-tab-label'),
)[0];
act(() => {
resultTabLabel.props.onPointerDown({
button: 0,
buttons: 1,
isPrimary: true,
target: { closest: () => null },
currentTarget: captureTarget,
pointerId: 7,
clientX: 100,
clientY: 100,
screenX: 300,
screenY: 300,
});
});
return captureTarget;
};
it('keeps the actual result table identity separate from metadata lookup names', async () => {
await act(async () => {
renderer = create(
<QueryEditorResultsPanel
resultSets={[{
key: 'result-1',
sql: 'select * from APP.USERS',
rows: [{ id: 1 }],
columns: ['id'],
tableName: 'APP.USERS',
metadataTableName: 'USERS',
pkColumns: ['id'],
readOnly: false,
}]}
activeResultKey="result-1"
isActive
loading={false}
executionError=""
sqlLogCount={0}
darkMode={false}
isV2Ui
currentDb="APP"
currentConnectionId="conn-1"
toggleShortcutLabel=""
onActiveResultKeyChange={vi.fn()}
onHide={vi.fn()}
onCloseResult={vi.fn()}
onCloseOtherResultTabs={vi.fn()}
onCloseResultTabsToLeft={vi.fn()}
onCloseResultTabsToRight={vi.fn()}
onCloseAllResultTabs={vi.fn()}
onResultPinnedChange={vi.fn()}
onOpenResultInWindow={vi.fn()}
onReloadResult={vi.fn()}
onResultPageChange={vi.fn()}
onResultSort={vi.fn()}
onDiagnoseExecutionError={vi.fn()}
/>,
);
});
expect(dataGridState.latestProps?.tableName).toBe('APP.USERS');
expect(dataGridState.latestProps?.dbName).toBe('APP');
});
it('restores selection state and removes global listeners when the window blurs', async () => {
const onOpenResultInWindow = vi.fn();
await renderDetachableResultPanel(onOpenResultInWindow);
const captureTarget = beginResultTabDrag();
act(() => {
windowTarget.dispatch('pointermove', {
pointerId: 7,
buttons: 1,
clientX: 120,
clientY: 130,
preventDefault: vi.fn(),
});
});
expect(documentTarget.body.style.userSelect).toBe('none');
expect(documentTarget.body.style.webkitUserSelect).toBe('none');
expect(classNames.has('gn-result-tab-detaching')).toBe(true);
expect(windowTarget.listenerCount('selectstart')).toBe(1);
expect(windowTarget.listenerCount('dragstart')).toBe(1);
expect(removeAllRanges).toHaveBeenCalled();
act(() => {
windowTarget.dispatch('blur');
windowTarget.dispatch('blur');
captureTarget.dispatch('lostpointercapture', { pointerId: 7 });
});
expect(documentTarget.body.style.userSelect).toBe('text');
expect(documentTarget.body.style.webkitUserSelect).toBe('auto');
expect(classNames.has('gn-result-tab-detaching')).toBe(false);
expect(windowTarget.listenerCount('pointermove')).toBe(0);
expect(windowTarget.listenerCount('pointerup')).toBe(0);
expect(windowTarget.listenerCount('pointercancel')).toBe(0);
expect(windowTarget.listenerCount('blur')).toBe(0);
expect(windowTarget.listenerCount('selectstart')).toBe(0);
expect(windowTarget.listenerCount('dragstart')).toBe(0);
expect(captureTarget.listenerCount('lostpointercapture')).toBe(0);
expect(captureTarget.releasePointerCapture).toHaveBeenCalledTimes(1);
expect(onOpenResultInWindow).not.toHaveBeenCalled();
});
it('ignores other pointers and cleans up when the active pointer loses capture', async () => {
await renderDetachableResultPanel();
const captureTarget = beginResultTabDrag();
act(() => {
windowTarget.dispatch('pointermove', {
pointerId: 9,
buttons: 0,
clientX: 180,
clientY: 180,
preventDefault: vi.fn(),
});
windowTarget.dispatch('pointerup', { pointerId: 9 });
captureTarget.dispatch('lostpointercapture', { pointerId: 9 });
});
expect(windowTarget.listenerCount('pointermove')).toBe(1);
expect(captureTarget.listenerCount('lostpointercapture')).toBe(1);
expect(captureTarget.releasePointerCapture).not.toHaveBeenCalled();
act(() => {
captureTarget.dispatch('lostpointercapture', { pointerId: 7 });
});
expect(windowTarget.listenerCount('pointermove')).toBe(0);
expect(windowTarget.listenerCount('pointerup')).toBe(0);
expect(windowTarget.listenerCount('pointercancel')).toBe(0);
expect(windowTarget.listenerCount('blur')).toBe(0);
expect(captureTarget.listenerCount('lostpointercapture')).toBe(0);
});
it('self-heals on buttons=0 even when releasing pointer capture throws', async () => {
const onOpenResultInWindow = vi.fn();
await renderDetachableResultPanel(onOpenResultInWindow);
const captureTarget = beginResultTabDrag(createResultTabPointerCaptureTarget(true));
expect(() => {
act(() => {
windowTarget.dispatch('pointermove', {
pointerId: 7,
buttons: 0,
clientX: 160,
clientY: 160,
preventDefault: vi.fn(),
});
});
}).not.toThrow();
expect(captureTarget.releasePointerCapture).toHaveBeenCalledWith(7);
expect(windowTarget.listenerCount('pointermove')).toBe(0);
expect(windowTarget.listenerCount('pointerup')).toBe(0);
expect(windowTarget.listenerCount('pointercancel')).toBe(0);
expect(windowTarget.listenerCount('blur')).toBe(0);
expect(captureTarget.listenerCount('lostpointercapture')).toBe(0);
expect(onOpenResultInWindow).not.toHaveBeenCalled();
});
it('restores active drag state when the result panel unmounts', async () => {
await renderDetachableResultPanel();
const captureTarget = beginResultTabDrag();
act(() => {
windowTarget.dispatch('pointermove', {
pointerId: 7,
buttons: 1,
clientX: 120,
clientY: 130,
preventDefault: vi.fn(),
});
});
expect(classNames.has('gn-result-tab-detaching')).toBe(true);
act(() => {
renderer?.unmount();
});
renderer = null;
expect(documentTarget.body.style.userSelect).toBe('text');
expect(documentTarget.body.style.webkitUserSelect).toBe('auto');
expect(classNames.has('gn-result-tab-detaching')).toBe(false);
expect(windowTarget.listenerCount('pointermove')).toBe(0);
expect(windowTarget.listenerCount('pointerup')).toBe(0);
expect(windowTarget.listenerCount('pointercancel')).toBe(0);
expect(windowTarget.listenerCount('blur')).toBe(0);
expect(windowTarget.listenerCount('selectstart')).toBe(0);
expect(windowTarget.listenerCount('dragstart')).toBe(0);
expect(captureTarget.listenerCount('lostpointercapture')).toBe(0);
expect(captureTarget.releasePointerCapture).toHaveBeenCalledWith(7);
});
});