mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-24 23:40:22 +08:00
- 新增 Redis 导入预览与按 key 勾选导入能力 - 后端支持导入文件预读、选中 key 过滤与本地化错误提示 - 打开数据库节点时同步展示加载态并补齐前后端回归测试 Fixes #607 Fixes #609
485 lines
14 KiB
TypeScript
485 lines
14 KiB
TypeScript
import React from 'react';
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import RedisViewer from './RedisViewer';
|
|
|
|
const storeState = vi.hoisted(() => ({
|
|
connections: [
|
|
{
|
|
id: 'redis-1',
|
|
name: 'redis',
|
|
config: {
|
|
type: 'redis',
|
|
host: '127.0.0.1',
|
|
port: 6379,
|
|
password: '',
|
|
database: '',
|
|
},
|
|
},
|
|
],
|
|
theme: 'light',
|
|
appearance: {
|
|
enabled: true,
|
|
opacity: 1,
|
|
blur: 0,
|
|
useNativeMacWindowControls: false,
|
|
},
|
|
}));
|
|
|
|
const redisBackend = vi.hoisted(() => ({
|
|
RedisScanKeys: vi.fn(),
|
|
RedisGetValue: vi.fn(),
|
|
RedisExportKeys: vi.fn(),
|
|
RedisPreviewImportKeys: vi.fn(),
|
|
RedisImportKeys: vi.fn(),
|
|
}));
|
|
|
|
const antdState = vi.hoisted(() => ({
|
|
treeProps: null as any,
|
|
message: {
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
warning: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../store', () => {
|
|
const useStore = Object.assign(
|
|
(selector: (state: typeof storeState) => any) => selector(storeState),
|
|
{ getState: () => storeState },
|
|
);
|
|
return { useStore };
|
|
});
|
|
|
|
vi.mock('@monaco-editor/react', async () => {
|
|
const React = await import('react');
|
|
return {
|
|
default: () => React.createElement('div', { 'data-monaco-editor': true }),
|
|
};
|
|
});
|
|
|
|
vi.mock('@ant-design/icons', async () => {
|
|
const React = await import('react');
|
|
const Icon = () => React.createElement('span', { 'data-icon': true });
|
|
return {
|
|
ReloadOutlined: Icon,
|
|
DeleteOutlined: Icon,
|
|
PlusOutlined: Icon,
|
|
EditOutlined: Icon,
|
|
SearchOutlined: Icon,
|
|
ClockCircleOutlined: Icon,
|
|
CopyOutlined: Icon,
|
|
FolderOpenOutlined: Icon,
|
|
KeyOutlined: Icon,
|
|
RightOutlined: Icon,
|
|
DownOutlined: Icon,
|
|
};
|
|
});
|
|
|
|
vi.mock('antd', async () => {
|
|
const React = await import('react');
|
|
const passthrough = (tag: string) => ({ children, ...props }: any) => React.createElement(tag, props, children);
|
|
const Button = ({ children, ...props }: any) => React.createElement('button', props, children);
|
|
const Input = Object.assign(
|
|
({ ...props }: any) => React.createElement('input', props),
|
|
{
|
|
Search: ({ ...props }: any) => React.createElement('input', props),
|
|
TextArea: ({ ...props }: any) => React.createElement('textarea', props),
|
|
},
|
|
);
|
|
const FormComponent = Object.assign(
|
|
({ children, ...props }: any) => React.createElement('form', props, children),
|
|
{
|
|
Item: passthrough('div'),
|
|
useForm: () => [{
|
|
validateFields: vi.fn(),
|
|
resetFields: vi.fn(),
|
|
setFieldsValue: vi.fn(),
|
|
}],
|
|
},
|
|
);
|
|
|
|
return {
|
|
Table: passthrough('div'),
|
|
Input,
|
|
Button,
|
|
Space: Object.assign(passthrough('div'), { Compact: passthrough('div') }),
|
|
Tag: passthrough('span'),
|
|
Tree: (props: any) => {
|
|
antdState.treeProps = props;
|
|
return React.createElement('redis-tree');
|
|
},
|
|
Spin: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
|
message: antdState.message,
|
|
Modal: Object.assign(({ children, open, onOk, onCancel, okButtonProps, title, ...props }: any) => {
|
|
if (!open) {
|
|
return null;
|
|
}
|
|
return React.createElement('div', props, [
|
|
React.createElement('div', { key: 'title', 'data-modal-title': true }, title),
|
|
children,
|
|
onOk ? React.createElement('button', { key: 'ok', onClick: onOk, disabled: okButtonProps?.disabled }, 'modal-ok') : null,
|
|
onCancel ? React.createElement('button', { key: 'cancel', onClick: onCancel }, 'modal-cancel') : null,
|
|
]);
|
|
}, { confirm: vi.fn() }),
|
|
Form: FormComponent,
|
|
InputNumber: ({ ...props }: any) => React.createElement('input', props),
|
|
Popconfirm: passthrough('span'),
|
|
Tooltip: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
|
Radio: {
|
|
Group: passthrough('div'),
|
|
Button,
|
|
},
|
|
};
|
|
});
|
|
|
|
const flushEffects = async () => {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
};
|
|
|
|
const collectRenderedText = (node: any): string => {
|
|
if (node == null || typeof node === 'boolean') return '';
|
|
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
|
if (Array.isArray(node)) return node.map(collectRenderedText).join('');
|
|
if (Array.isArray(node.children)) return node.children.map(collectRenderedText).join('');
|
|
return '';
|
|
};
|
|
|
|
const findButtonByText = (renderer: ReactTestRenderer, text: string) => {
|
|
return renderer.root.findAllByType('button').find((node) => collectRenderedText(node.props.children).includes(text));
|
|
};
|
|
|
|
const countLeafNodes = (nodes: any[]): number => {
|
|
return nodes.reduce((total, node) => {
|
|
if (!node || typeof node !== 'object') {
|
|
return total;
|
|
}
|
|
if (node.nodeType === 'leaf') {
|
|
return total + 1;
|
|
}
|
|
return total + countLeafNodes(Array.isArray(node.children) ? node.children : []);
|
|
}, 0);
|
|
};
|
|
|
|
const findFirstLeafNode = (nodes: any[]): any | null => {
|
|
for (const node of nodes) {
|
|
if (!node || typeof node !== 'object') {
|
|
continue;
|
|
}
|
|
if (node.nodeType === 'leaf') {
|
|
return node;
|
|
}
|
|
if (Array.isArray(node.children)) {
|
|
const nested = findFirstLeafNode(node.children);
|
|
if (nested) {
|
|
return nested;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
describe('RedisViewer tree interactions', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
antdState.treeProps = null;
|
|
storeState.connections = [
|
|
{
|
|
id: 'redis-1',
|
|
name: 'redis',
|
|
config: {
|
|
type: 'redis',
|
|
host: '127.0.0.1',
|
|
port: 6379,
|
|
password: '',
|
|
database: '',
|
|
},
|
|
},
|
|
];
|
|
redisBackend.RedisScanKeys.mockResolvedValue({
|
|
success: true,
|
|
data: {
|
|
cursor: '0',
|
|
keys: [
|
|
{ key: 'app:user:1', type: 'string', ttl: -1 },
|
|
{ key: 'app:user:2', type: 'string', ttl: -1 },
|
|
],
|
|
},
|
|
});
|
|
redisBackend.RedisGetValue.mockResolvedValue({
|
|
success: true,
|
|
data: { key: 'app:user:1', type: 'string', ttl: -1, value: 'demo' },
|
|
});
|
|
redisBackend.RedisExportKeys.mockResolvedValue({
|
|
success: true,
|
|
data: { exported: 2 },
|
|
});
|
|
redisBackend.RedisPreviewImportKeys.mockResolvedValue({
|
|
success: true,
|
|
data: {
|
|
file: 'C:\\tmp\\redis-keys.json',
|
|
database: 0,
|
|
total: 2,
|
|
keys: [
|
|
{ key: 'app:user:1', type: 'string', ttl: -1 },
|
|
{ key: 'app:user:2', type: 'string', ttl: 120 },
|
|
],
|
|
},
|
|
});
|
|
redisBackend.RedisImportKeys.mockResolvedValue({
|
|
success: true,
|
|
data: { imported: 1, skipped: 0, total: 1 },
|
|
});
|
|
vi.stubGlobal('window', {
|
|
innerWidth: 1280,
|
|
innerHeight: 800,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
go: {
|
|
app: {
|
|
App: redisBackend,
|
|
},
|
|
},
|
|
});
|
|
vi.stubGlobal('ResizeObserver', undefined);
|
|
});
|
|
|
|
it('toggles namespace expansion from row clicks without checking the group', async () => {
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
|
|
});
|
|
await flushEffects();
|
|
|
|
const appGroup = antdState.treeProps.treeData.find((node: any) => node.key === 'group:app');
|
|
expect(appGroup).toBeTruthy();
|
|
expect(antdState.treeProps.expandedKeys).not.toContain('group:app');
|
|
|
|
const groupTitle = antdState.treeProps.titleRender(appGroup);
|
|
expect(typeof groupTitle.props.onClick).toBe('function');
|
|
|
|
const event = {
|
|
preventDefault: vi.fn(),
|
|
stopPropagation: vi.fn(),
|
|
};
|
|
await act(async () => {
|
|
groupTitle.props.onClick(event);
|
|
});
|
|
|
|
expect(event.preventDefault).toHaveBeenCalled();
|
|
expect(event.stopPropagation).toHaveBeenCalled();
|
|
expect(antdState.treeProps.expandedKeys).toContain('group:app');
|
|
expect(antdState.treeProps.checkedKeys.checked).toEqual([]);
|
|
|
|
renderer!.unmount();
|
|
});
|
|
|
|
it('shows Redis Cluster topology context in the key explorer header', async () => {
|
|
storeState.connections = [
|
|
{
|
|
id: 'redis-1',
|
|
name: 'redis-cluster',
|
|
config: {
|
|
type: 'redis',
|
|
host: '10.0.0.1',
|
|
port: 6379,
|
|
hosts: ['10.0.0.2:6379', '10.0.0.3:6379'],
|
|
topology: 'cluster',
|
|
password: '',
|
|
database: '',
|
|
} as any,
|
|
},
|
|
];
|
|
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<RedisViewer connectionId="redis-1" redisDB={2} />);
|
|
});
|
|
await flushEffects();
|
|
|
|
const renderedText = collectRenderedText(renderer!.toJSON());
|
|
expect(renderedText).toContain('db2');
|
|
expect(renderedText).toContain('Cluster');
|
|
expect(renderedText).toContain('3 nodes');
|
|
|
|
renderer!.unmount();
|
|
});
|
|
|
|
it('loads every key page when the load-all action is clicked', async () => {
|
|
redisBackend.RedisScanKeys.mockReset();
|
|
redisBackend.RedisScanKeys
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
data: {
|
|
cursor: '1',
|
|
keys: [
|
|
{ key: 'app:user:1', type: 'string', ttl: -1 },
|
|
{ key: 'app:user:2', type: 'string', ttl: -1 },
|
|
],
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
data: {
|
|
cursor: '1',
|
|
keys: [
|
|
{ key: 'app:user:1', type: 'string', ttl: -1 },
|
|
{ key: 'app:user:2', type: 'string', ttl: -1 },
|
|
],
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
data: {
|
|
cursor: '0',
|
|
keys: [
|
|
{ key: 'app:user:3', type: 'string', ttl: -1 },
|
|
],
|
|
},
|
|
});
|
|
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
|
|
});
|
|
await flushEffects();
|
|
|
|
const loadAllButton = findButtonByText(renderer!, 'Load all');
|
|
expect(loadAllButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
loadAllButton!.props.onClick?.();
|
|
});
|
|
await flushEffects();
|
|
|
|
expect(redisBackend.RedisScanKeys).toHaveBeenCalledTimes(3);
|
|
expect(redisBackend.RedisScanKeys.mock.calls[1]?.[2]).toBe('0');
|
|
expect(redisBackend.RedisScanKeys.mock.calls[2]?.[2]).toBe('1');
|
|
|
|
expect(countLeafNodes(antdState.treeProps.treeData)).toBe(3);
|
|
|
|
const renderedText = collectRenderedText(renderer!.toJSON());
|
|
expect(renderedText).toContain('Loaded 3 Keys');
|
|
|
|
renderer!.unmount();
|
|
});
|
|
|
|
it('exports the current filtered key set when the export-all action is clicked', async () => {
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
|
|
});
|
|
await flushEffects();
|
|
|
|
const exportAllButton = findButtonByText(renderer!, 'Export all');
|
|
expect(exportAllButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
exportAllButton!.props.onClick?.();
|
|
});
|
|
await flushEffects();
|
|
|
|
expect(redisBackend.RedisExportKeys).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
|
|
{ scope: 'all', keys: [], pattern: '*' },
|
|
);
|
|
|
|
renderer!.unmount();
|
|
});
|
|
|
|
it('exports checked leaf keys when the export-selected action is clicked', async () => {
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
|
|
});
|
|
await flushEffects();
|
|
|
|
const leafNode = findFirstLeafNode(antdState.treeProps.treeData);
|
|
expect(leafNode?.rawKey).toBe('app:user:1');
|
|
|
|
await act(async () => {
|
|
antdState.treeProps.onCheck?.(
|
|
{ checked: [leafNode.key], halfChecked: [] },
|
|
{ checked: true, node: leafNode },
|
|
);
|
|
});
|
|
await flushEffects();
|
|
|
|
const exportSelectedButton = findButtonByText(renderer!, 'Export selected');
|
|
expect(exportSelectedButton).toBeTruthy();
|
|
expect(exportSelectedButton?.props.disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
exportSelectedButton!.props.onClick?.();
|
|
});
|
|
await flushEffects();
|
|
|
|
expect(redisBackend.RedisExportKeys).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
|
|
{ scope: 'selected', keys: ['app:user:1'], pattern: '*' },
|
|
);
|
|
|
|
renderer!.unmount();
|
|
});
|
|
|
|
it('imports only the checked preview keys from the selected file', async () => {
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
|
|
});
|
|
await flushEffects();
|
|
|
|
const importButton = findButtonByText(renderer!, 'Import');
|
|
expect(importButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
importButton!.props.onClick?.();
|
|
});
|
|
await flushEffects();
|
|
|
|
const chooseFileButton = findButtonByText(renderer!, 'Select import file');
|
|
expect(chooseFileButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
chooseFileButton!.props.onClick?.();
|
|
});
|
|
await flushEffects();
|
|
|
|
expect(redisBackend.RedisPreviewImportKeys).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
|
|
);
|
|
|
|
const secondCheckbox = renderer!.root.findByProps({ 'data-import-key': 'app:user:2' });
|
|
await act(async () => {
|
|
secondCheckbox.props.onChange?.({ target: { checked: false } });
|
|
});
|
|
await flushEffects();
|
|
|
|
const modalOkButton = findButtonByText(renderer!, 'modal-ok');
|
|
expect(modalOkButton).toBeTruthy();
|
|
expect(modalOkButton?.props.disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
modalOkButton!.props.onClick?.();
|
|
});
|
|
await flushEffects();
|
|
|
|
expect(redisBackend.RedisImportKeys).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
|
|
{
|
|
conflictMode: 'overwrite',
|
|
file: 'C:\\tmp\\redis-keys.json',
|
|
scope: 'selected',
|
|
keys: ['app:user:1'],
|
|
},
|
|
);
|
|
|
|
renderer!.unmount();
|
|
});
|
|
});
|