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(); }); 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(); }); 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(); }); 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(); }); 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(); }); 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(); }); 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(); }); });