| /** | |||||
| * Workflow Panel Width Persistence Tests | |||||
| * Tests for GitHub issue #22745: Panel width persistence bug fix | |||||
| */ | |||||
| import '@testing-library/jest-dom' | |||||
| type PanelWidthSource = 'user' | 'system' | |||||
| // Mock localStorage for testing | |||||
| const createMockLocalStorage = () => { | |||||
| const storage: Record<string, string> = {} | |||||
| return { | |||||
| getItem: jest.fn((key: string) => storage[key] || null), | |||||
| setItem: jest.fn((key: string, value: string) => { | |||||
| storage[key] = value | |||||
| }), | |||||
| removeItem: jest.fn((key: string) => { | |||||
| delete storage[key] | |||||
| }), | |||||
| clear: jest.fn(() => { | |||||
| Object.keys(storage).forEach(key => delete storage[key]) | |||||
| }), | |||||
| get storage() { return { ...storage } }, | |||||
| } | |||||
| } | |||||
| // Core panel width logic extracted from the component | |||||
| const createPanelWidthManager = (storageKey: string) => { | |||||
| return { | |||||
| updateWidth: (width: number, source: PanelWidthSource = 'user') => { | |||||
| const newValue = Math.max(400, Math.min(width, 800)) | |||||
| if (source === 'user') | |||||
| localStorage.setItem(storageKey, `${newValue}`) | |||||
| return newValue | |||||
| }, | |||||
| getStoredWidth: () => { | |||||
| const stored = localStorage.getItem(storageKey) | |||||
| return stored ? Number.parseFloat(stored) : 400 | |||||
| }, | |||||
| } | |||||
| } | |||||
| describe('Workflow Panel Width Persistence', () => { | |||||
| let mockLocalStorage: ReturnType<typeof createMockLocalStorage> | |||||
| beforeEach(() => { | |||||
| mockLocalStorage = createMockLocalStorage() | |||||
| Object.defineProperty(globalThis, 'localStorage', { | |||||
| value: mockLocalStorage, | |||||
| writable: true, | |||||
| }) | |||||
| }) | |||||
| afterEach(() => { | |||||
| jest.clearAllMocks() | |||||
| }) | |||||
| describe('Node Panel Width Management', () => { | |||||
| const storageKey = 'workflow-node-panel-width' | |||||
| it('should save user resize to localStorage', () => { | |||||
| const manager = createPanelWidthManager(storageKey) | |||||
| const result = manager.updateWidth(500, 'user') | |||||
| expect(result).toBe(500) | |||||
| expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, '500') | |||||
| }) | |||||
| it('should not save system compression to localStorage', () => { | |||||
| const manager = createPanelWidthManager(storageKey) | |||||
| const result = manager.updateWidth(200, 'system') | |||||
| expect(result).toBe(400) // Respects minimum width | |||||
| expect(localStorage.setItem).not.toHaveBeenCalled() | |||||
| }) | |||||
| it('should enforce minimum width of 400px', () => { | |||||
| const manager = createPanelWidthManager(storageKey) | |||||
| // User tries to set below minimum | |||||
| const userResult = manager.updateWidth(300, 'user') | |||||
| expect(userResult).toBe(400) | |||||
| expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, '400') | |||||
| // System compression below minimum | |||||
| const systemResult = manager.updateWidth(150, 'system') | |||||
| expect(systemResult).toBe(400) | |||||
| expect(localStorage.setItem).toHaveBeenCalledTimes(1) // Only user call | |||||
| }) | |||||
| it('should preserve user preferences during system compression', () => { | |||||
| localStorage.setItem(storageKey, '600') | |||||
| const manager = createPanelWidthManager(storageKey) | |||||
| // System compresses panel | |||||
| manager.updateWidth(200, 'system') | |||||
| // User preference should remain unchanged | |||||
| expect(localStorage.getItem(storageKey)).toBe('600') | |||||
| }) | |||||
| }) | |||||
| describe('Bug Scenario Reproduction', () => { | |||||
| it('should reproduce original bug behavior (for comparison)', () => { | |||||
| const storageKey = 'workflow-node-panel-width' | |||||
| // Original buggy behavior - always saves regardless of source | |||||
| const buggyUpdate = (width: number) => { | |||||
| localStorage.setItem(storageKey, `${width}`) | |||||
| return Math.max(400, width) | |||||
| } | |||||
| localStorage.setItem(storageKey, '500') // User preference | |||||
| buggyUpdate(200) // System compression pollutes localStorage | |||||
| expect(localStorage.getItem(storageKey)).toBe('200') // Bug: corrupted state | |||||
| }) | |||||
| it('should verify fix prevents localStorage pollution', () => { | |||||
| const storageKey = 'workflow-node-panel-width' | |||||
| const manager = createPanelWidthManager(storageKey) | |||||
| localStorage.setItem(storageKey, '500') // User preference | |||||
| manager.updateWidth(200, 'system') // System compression | |||||
| expect(localStorage.getItem(storageKey)).toBe('500') // Fix: preserved state | |||||
| }) | |||||
| }) | |||||
| describe('Edge Cases', () => { | |||||
| it('should handle multiple rapid operations correctly', () => { | |||||
| const manager = createPanelWidthManager('workflow-node-panel-width') | |||||
| // Rapid system adjustments | |||||
| manager.updateWidth(300, 'system') | |||||
| manager.updateWidth(250, 'system') | |||||
| manager.updateWidth(180, 'system') | |||||
| // Single user adjustment | |||||
| manager.updateWidth(550, 'user') | |||||
| expect(localStorage.setItem).toHaveBeenCalledTimes(1) | |||||
| expect(localStorage.setItem).toHaveBeenCalledWith('workflow-node-panel-width', '550') | |||||
| }) | |||||
| it('should handle corrupted localStorage gracefully', () => { | |||||
| localStorage.setItem('workflow-node-panel-width', '150') // Below minimum | |||||
| const manager = createPanelWidthManager('workflow-node-panel-width') | |||||
| const storedWidth = manager.getStoredWidth() | |||||
| expect(storedWidth).toBe(150) // Returns raw value | |||||
| // User can correct the preference | |||||
| const correctedWidth = manager.updateWidth(500, 'user') | |||||
| expect(correctedWidth).toBe(500) | |||||
| expect(localStorage.getItem('workflow-node-panel-width')).toBe('500') | |||||
| }) | |||||
| }) | |||||
| describe('TypeScript Type Safety', () => { | |||||
| it('should enforce source parameter type', () => { | |||||
| const manager = createPanelWidthManager('workflow-node-panel-width') | |||||
| // Valid source values | |||||
| manager.updateWidth(500, 'user') | |||||
| manager.updateWidth(500, 'system') | |||||
| // Default to 'user' | |||||
| manager.updateWidth(500) | |||||
| expect(localStorage.setItem).toHaveBeenCalledTimes(2) // user + default | |||||
| }) | |||||
| }) | |||||
| }) |
| return Math.max(available, 400) | return Math.max(available, 400) | ||||
| }, [workflowCanvasWidth, otherPanelWidth]) | }, [workflowCanvasWidth, otherPanelWidth]) | ||||
| const updateNodePanelWidth = useCallback((width: number) => { | |||||
| const updateNodePanelWidth = useCallback((width: number, source: 'user' | 'system' = 'user') => { | |||||
| // Ensure the width is within the min and max range | // Ensure the width is within the min and max range | ||||
| const newValue = Math.max(400, Math.min(width, maxNodePanelWidth)) | const newValue = Math.max(400, Math.min(width, maxNodePanelWidth)) | ||||
| localStorage.setItem('workflow-node-panel-width', `${newValue}`) | |||||
| if (source === 'user') | |||||
| localStorage.setItem('workflow-node-panel-width', `${newValue}`) | |||||
| setNodePanelWidth(newValue) | setNodePanelWidth(newValue) | ||||
| }, [maxNodePanelWidth, setNodePanelWidth]) | }, [maxNodePanelWidth, setNodePanelWidth]) | ||||
| const handleResize = useCallback((width: number) => { | const handleResize = useCallback((width: number) => { | ||||
| updateNodePanelWidth(width) | |||||
| updateNodePanelWidth(width, 'user') | |||||
| }, [updateNodePanelWidth]) | }, [updateNodePanelWidth]) | ||||
| const { | const { | ||||
| onResize: debounce(handleResize), | onResize: debounce(handleResize), | ||||
| }) | }) | ||||
| const debounceUpdate = debounce(updateNodePanelWidth) | |||||
| const debounceUpdate = debounce((width: number) => { | |||||
| updateNodePanelWidth(width, 'system') | |||||
| }) | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (!workflowCanvasWidth) | if (!workflowCanvasWidth) | ||||
| return | return | ||||
| const target = Math.max(workflowCanvasWidth - otherPanelWidth - reservedCanvasWidth, 400) | const target = Math.max(workflowCanvasWidth - otherPanelWidth - reservedCanvasWidth, 400) | ||||
| debounceUpdate(target) | debounceUpdate(target) | ||||
| } | } | ||||
| }, [nodePanelWidth, otherPanelWidth, workflowCanvasWidth, updateNodePanelWidth]) | |||||
| }, [nodePanelWidth, otherPanelWidth, workflowCanvasWidth, debounceUpdate]) | |||||
| const { handleNodeSelect } = useNodesInteractions() | const { handleNodeSelect } = useNodesInteractions() | ||||
| const { nodesReadOnly } = useNodesReadOnly() | const { nodesReadOnly } = useNodesReadOnly() |
| /** | |||||
| * Debug and Preview Panel Width Persistence Tests | |||||
| * Tests for GitHub issue #22745: Panel width persistence bug fix | |||||
| */ | |||||
| import '@testing-library/jest-dom' | |||||
| type PanelWidthSource = 'user' | 'system' | |||||
| // Mock localStorage for testing | |||||
| const createMockLocalStorage = () => { | |||||
| const storage: Record<string, string> = {} | |||||
| return { | |||||
| getItem: jest.fn((key: string) => storage[key] || null), | |||||
| setItem: jest.fn((key: string, value: string) => { | |||||
| storage[key] = value | |||||
| }), | |||||
| removeItem: jest.fn((key: string) => { | |||||
| delete storage[key] | |||||
| }), | |||||
| clear: jest.fn(() => { | |||||
| Object.keys(storage).forEach(key => delete storage[key]) | |||||
| }), | |||||
| get storage() { return { ...storage } }, | |||||
| } | |||||
| } | |||||
| // Preview panel width logic | |||||
| const createPreviewPanelManager = () => { | |||||
| const storageKey = 'debug-and-preview-panel-width' | |||||
| return { | |||||
| updateWidth: (width: number, source: PanelWidthSource = 'user') => { | |||||
| const newValue = Math.max(400, Math.min(width, 800)) | |||||
| if (source === 'user') | |||||
| localStorage.setItem(storageKey, `${newValue}`) | |||||
| return newValue | |||||
| }, | |||||
| getStoredWidth: () => { | |||||
| const stored = localStorage.getItem(storageKey) | |||||
| return stored ? Number.parseFloat(stored) : 400 | |||||
| }, | |||||
| } | |||||
| } | |||||
| describe('Debug and Preview Panel Width Persistence', () => { | |||||
| let mockLocalStorage: ReturnType<typeof createMockLocalStorage> | |||||
| beforeEach(() => { | |||||
| mockLocalStorage = createMockLocalStorage() | |||||
| Object.defineProperty(globalThis, 'localStorage', { | |||||
| value: mockLocalStorage, | |||||
| writable: true, | |||||
| }) | |||||
| }) | |||||
| afterEach(() => { | |||||
| jest.clearAllMocks() | |||||
| }) | |||||
| describe('Preview Panel Width Management', () => { | |||||
| it('should save user resize to localStorage', () => { | |||||
| const manager = createPreviewPanelManager() | |||||
| const result = manager.updateWidth(450, 'user') | |||||
| expect(result).toBe(450) | |||||
| expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '450') | |||||
| }) | |||||
| it('should not save system compression to localStorage', () => { | |||||
| const manager = createPreviewPanelManager() | |||||
| const result = manager.updateWidth(300, 'system') | |||||
| expect(result).toBe(400) // Respects minimum width | |||||
| expect(localStorage.setItem).not.toHaveBeenCalled() | |||||
| }) | |||||
| it('should behave identically to Node Panel', () => { | |||||
| const manager = createPreviewPanelManager() | |||||
| // Both user and system operations should behave consistently | |||||
| manager.updateWidth(500, 'user') | |||||
| expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500') | |||||
| manager.updateWidth(200, 'system') | |||||
| expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500') | |||||
| }) | |||||
| }) | |||||
| describe('Dual Panel Scenario', () => { | |||||
| it('should maintain independence from Node Panel', () => { | |||||
| localStorage.setItem('workflow-node-panel-width', '600') | |||||
| localStorage.setItem('debug-and-preview-panel-width', '450') | |||||
| const manager = createPreviewPanelManager() | |||||
| // System compresses preview panel | |||||
| manager.updateWidth(200, 'system') | |||||
| // Only preview panel storage key should be unaffected | |||||
| expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('450') | |||||
| expect(localStorage.getItem('workflow-node-panel-width')).toBe('600') | |||||
| }) | |||||
| it('should handle F12 scenario consistently', () => { | |||||
| const manager = createPreviewPanelManager() | |||||
| // User sets preference | |||||
| manager.updateWidth(500, 'user') | |||||
| expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500') | |||||
| // F12 opens causing viewport compression | |||||
| manager.updateWidth(180, 'system') | |||||
| // User preference preserved | |||||
| expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500') | |||||
| }) | |||||
| }) | |||||
| describe('Consistency with Node Panel', () => { | |||||
| it('should enforce same minimum width rules', () => { | |||||
| const manager = createPreviewPanelManager() | |||||
| // Same 400px minimum as Node Panel | |||||
| const result = manager.updateWidth(300, 'user') | |||||
| expect(result).toBe(400) | |||||
| expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '400') | |||||
| }) | |||||
| it('should use same source parameter pattern', () => { | |||||
| const manager = createPreviewPanelManager() | |||||
| // Default to 'user' when source not specified | |||||
| manager.updateWidth(500) | |||||
| expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500') | |||||
| // Explicit 'system' source | |||||
| manager.updateWidth(300, 'system') | |||||
| expect(localStorage.setItem).toHaveBeenCalledTimes(1) // Only user call | |||||
| }) | |||||
| }) | |||||
| }) |
| const nodePanelWidth = useStore(s => s.nodePanelWidth) | const nodePanelWidth = useStore(s => s.nodePanelWidth) | ||||
| const panelWidth = useStore(s => s.previewPanelWidth) | const panelWidth = useStore(s => s.previewPanelWidth) | ||||
| const setPanelWidth = useStore(s => s.setPreviewPanelWidth) | const setPanelWidth = useStore(s => s.setPreviewPanelWidth) | ||||
| const handleResize = useCallback((width: number) => { | |||||
| localStorage.setItem('debug-and-preview-panel-width', `${width}`) | |||||
| const handleResize = useCallback((width: number, source: 'user' | 'system' = 'user') => { | |||||
| if (source === 'user') | |||||
| localStorage.setItem('debug-and-preview-panel-width', `${width}`) | |||||
| setPanelWidth(width) | setPanelWidth(width) | ||||
| }, [setPanelWidth]) | }, [setPanelWidth]) | ||||
| const maxPanelWidth = useMemo(() => { | const maxPanelWidth = useMemo(() => { | ||||
| triggerDirection: 'left', | triggerDirection: 'left', | ||||
| minWidth: 400, | minWidth: 400, | ||||
| maxWidth: maxPanelWidth, | maxWidth: maxPanelWidth, | ||||
| onResize: debounce(handleResize), | |||||
| onResize: debounce((width: number) => { | |||||
| handleResize(width, 'user') | |||||
| }), | |||||
| }) | }) | ||||
| return ( | return ( |