/** * @jest-environment jsdom */ import { LiveComponentDevTools } from '../../../../../resources/js/modules/LiveComponentDevTools.js'; import { Core } from '../../../../../resources/js/modules/core.js'; describe('LiveComponentDevTools', () => { let devTools; let mockLocalStorage; beforeEach(() => { // Reset DOM document.body.innerHTML = ''; // Mock localStorage mockLocalStorage = {}; global.localStorage = { getItem: jest.fn((key) => mockLocalStorage[key] || null), setItem: jest.fn((key, value) => { mockLocalStorage[key] = value; }), removeItem: jest.fn((key) => { delete mockLocalStorage[key]; }), clear: jest.fn(() => { mockLocalStorage = {}; }) }; // Set development environment document.documentElement.dataset.env = 'development'; // Mock Core.emit Core.emit = jest.fn(); Core.on = jest.fn(); // Mock performance.memory (Chrome-specific) if (!window.performance.memory) { window.performance.memory = { usedJSHeapSize: 10000000, totalJSHeapSize: 20000000, jsHeapSizeLimit: 100000000 }; } // Mock window.LiveComponent window.LiveComponent = { enableDevTools: jest.fn() }; }); afterEach(() => { // Cleanup if (devTools && devTools.overlay) { devTools.overlay.remove(); } devTools = null; }); describe('Initialization', () => { it('initializes in development mode', () => { document.documentElement.dataset.env = 'development'; devTools = new LiveComponentDevTools(); expect(devTools.isEnabled).toBe(true); expect(devTools.overlay).not.toBeNull(); expect(document.getElementById('livecomponent-devtools')).not.toBeNull(); }); it('does not initialize in production mode by default', () => { document.documentElement.dataset.env = 'production'; devTools = new LiveComponentDevTools(); expect(devTools.isEnabled).toBe(false); expect(devTools.overlay).toBeNull(); }); it('initializes in production with explicit localStorage flag', () => { document.documentElement.dataset.env = 'production'; mockLocalStorage['livecomponent_devtools'] = 'true'; devTools = new LiveComponentDevTools(); expect(devTools.isEnabled).toBe(true); expect(devTools.overlay).not.toBeNull(); }); it('creates all required UI elements', () => { devTools = new LiveComponentDevTools(); const overlay = document.getElementById('livecomponent-devtools'); expect(overlay).not.toBeNull(); // Check header expect(overlay.querySelector('.lc-devtools__header')).not.toBeNull(); expect(overlay.querySelector('.lc-devtools__title')).not.toBeNull(); // Check tabs expect(overlay.querySelector('[data-tab="components"]')).not.toBeNull(); expect(overlay.querySelector('[data-tab="actions"]')).not.toBeNull(); expect(overlay.querySelector('[data-tab="events"]')).not.toBeNull(); expect(overlay.querySelector('[data-tab="performance"]')).not.toBeNull(); expect(overlay.querySelector('[data-tab="network"]')).not.toBeNull(); // Check panes expect(overlay.querySelector('[data-pane="components"]')).not.toBeNull(); expect(overlay.querySelector('[data-pane="actions"]')).not.toBeNull(); expect(overlay.querySelector('[data-pane="events"]')).not.toBeNull(); expect(overlay.querySelector('[data-pane="performance"]')).not.toBeNull(); expect(overlay.querySelector('[data-pane="network"]')).not.toBeNull(); // Check action buttons expect(overlay.querySelector('[data-action="minimize"]')).not.toBeNull(); expect(overlay.querySelector('[data-action="close"]')).not.toBeNull(); expect(overlay.querySelector('[data-action="toggle-badges"]')).not.toBeNull(); }); it('connects to LiveComponent manager', (done) => { devTools = new LiveComponentDevTools(); setTimeout(() => { expect(window.LiveComponent.enableDevTools).toHaveBeenCalledWith(devTools); done(); }, 150); }); }); describe('Component Discovery', () => { beforeEach(() => { devTools = new LiveComponentDevTools(); }); it('discovers components via data-component-id attribute', () => { document.body.innerHTML = `
Count: 0

Stats

`; devTools.refreshComponents(); expect(devTools.components.size).toBe(2); expect(devTools.components.has('counter:demo')).toBe(true); expect(devTools.components.has('user-stats:123')).toBe(true); const counter = devTools.components.get('counter:demo'); expect(counter.name).toBe('Counter'); expect(counter.id).toBe('counter:demo'); }); it('extracts component props from data attributes', () => { document.body.innerHTML = `
Product
`; devTools.refreshComponents(); const product = devTools.components.get('product:5'); expect(product.props.productId).toBe('5'); expect(product.props.category).toBe('electronics'); expect(product.props.componentId).toBeUndefined(); // Excluded expect(product.props.componentName).toBeUndefined(); // Excluded }); it('handles components without data-component-name', () => { document.body.innerHTML = `
Content
`; devTools.refreshComponents(); const component = devTools.components.get('anonymous:1'); expect(component.name).toBe('Unknown'); }); }); describe('Action Logging', () => { beforeEach(() => { devTools = new LiveComponentDevTools(); }); it('logs action executions', () => { const startTime = 100; const endTime = 150; devTools.logAction('counter:demo', 'increment', { amount: 5 }, startTime, endTime, true); expect(devTools.actionLog.length).toBe(1); const logEntry = devTools.actionLog[0]; expect(logEntry.componentId).toBe('counter:demo'); expect(logEntry.actionName).toBe('increment'); expect(logEntry.params).toEqual({ amount: 5 }); expect(logEntry.duration).toBe(50); expect(logEntry.success).toBe(true); expect(logEntry.error).toBeNull(); }); it('logs action failures with error messages', () => { devTools.logAction('form:contact', 'submit', {}, 100, 200, false, 'Validation failed'); const logEntry = devTools.actionLog[0]; expect(logEntry.success).toBe(false); expect(logEntry.error).toBe('Validation failed'); }); it('maintains max 100 action log entries', () => { // Add 110 actions for (let i = 0; i < 110; i++) { devTools.logAction(`component:${i}`, 'action', {}, 0, 10, true); } expect(devTools.actionLog.length).toBe(100); // Most recent should be at index 0 expect(devTools.actionLog[0].componentId).toBe('component:109'); }); it('records action execution for performance profiling when recording', () => { devTools.isRecording = true; devTools.logAction('counter:demo', 'increment', {}, 100, 150, true); expect(devTools.performanceRecording.length).toBe(1); expect(devTools.performanceRecording[0].type).toBe('action'); expect(devTools.performanceRecording[0].duration).toBe(50); }); it('does not record performance when not recording', () => { devTools.isRecording = false; devTools.logAction('counter:demo', 'increment', {}, 100, 150, true); expect(devTools.performanceRecording.length).toBe(0); }); }); describe('Event Logging', () => { beforeEach(() => { devTools = new LiveComponentDevTools(); }); it('logs events with source tracking', () => { devTools.logEvent('user:updated', { userId: 123 }, 'client'); expect(devTools.eventLog.length).toBe(1); const logEntry = devTools.eventLog[0]; expect(logEntry.eventName).toBe('user:updated'); expect(logEntry.data).toEqual({ userId: 123 }); expect(logEntry.source).toBe('client'); }); it('defaults source to client', () => { devTools.logEvent('notification:received', { message: 'Hello' }); expect(devTools.eventLog[0].source).toBe('client'); }); it('maintains max 100 event log entries', () => { // Add 110 events for (let i = 0; i < 110; i++) { devTools.logEvent(`event:${i}`, {}, 'client'); } expect(devTools.eventLog.length).toBe(100); // Most recent should be at index 0 expect(devTools.eventLog[0].eventName).toBe('event:109'); }); it('intercepts Core.emit for automatic event logging', () => { const originalEmit = Core.emit; devTools.interceptCoreEmit(); Core.emit('test:event', { data: 'test' }); expect(devTools.eventLog.length).toBe(1); expect(devTools.eventLog[0].eventName).toBe('test:event'); expect(devTools.eventLog[0].data).toEqual({ data: 'test' }); expect(devTools.eventLog[0].source).toBe('client'); }); }); describe('Network Monitoring', () => { beforeEach(() => { devTools = new LiveComponentDevTools(); // Mock original fetch global.originalFetch = global.fetch; global.fetch = jest.fn(); }); afterEach(() => { // Restore original fetch global.fetch = global.originalFetch; }); it('intercepts fetch requests', async () => { global.fetch.mockResolvedValue({ status: 200, ok: true }); devTools.monitorNetworkRequests(); await window.fetch('/api/users'); expect(devTools.networkLog.length).toBe(1); const logEntry = devTools.networkLog[0]; expect(logEntry.method).toBe('GET'); expect(logEntry.url).toBe('/api/users'); expect(logEntry.status).toBe(200); expect(logEntry.duration).toBeGreaterThanOrEqual(0); }); it('logs POST requests with correct method', async () => { global.fetch.mockResolvedValue({ status: 201, ok: true }); devTools.monitorNetworkRequests(); await window.fetch('/api/users', { method: 'POST' }); expect(devTools.networkLog[0].method).toBe('POST'); expect(devTools.networkLog[0].status).toBe(201); }); it('maintains max 50 network log entries', async () => { global.fetch.mockResolvedValue({ status: 200, ok: true }); devTools.monitorNetworkRequests(); // Make 60 requests for (let i = 0; i < 60; i++) { await window.fetch(`/api/request/${i}`); } expect(devTools.networkLog.length).toBe(50); }); it('logs failed requests', async () => { global.fetch.mockRejectedValue(new Error('Network error')); devTools.monitorNetworkRequests(); try { await window.fetch('/api/fail'); } catch (error) { // Expected } expect(devTools.networkLog.length).toBe(1); expect(devTools.networkLog[0].status).toBe('Error'); }); }); describe('Tab Switching', () => { beforeEach(() => { devTools = new LiveComponentDevTools(); devTools.open(); }); it('switches to actions tab', () => { devTools.switchTab('actions'); expect(devTools.activeTab).toBe('actions'); const actionsTab = devTools.overlay.querySelector('[data-tab="actions"]'); const actionsPane = devTools.overlay.querySelector('[data-pane="actions"]'); expect(actionsTab.classList.contains('lc-devtools__tab--active')).toBe(true); expect(actionsPane.classList.contains('lc-devtools__pane--active')).toBe(true); }); it('deactivates previous tab when switching', () => { devTools.switchTab('events'); const componentsTab = devTools.overlay.querySelector('[data-tab="components"]'); const componentsPane = devTools.overlay.querySelector('[data-pane="components"]'); expect(componentsTab.classList.contains('lc-devtools__tab--active')).toBe(false); expect(componentsPane.classList.contains('lc-devtools__pane--active')).toBe(false); }); it('refreshes content when switching tabs', () => { devTools.renderActionLog = jest.fn(); devTools.switchTab('actions'); expect(devTools.renderActionLog).toHaveBeenCalled(); }); }); describe('Open/Close/Minimize', () => { beforeEach(() => { devTools = new LiveComponentDevTools(); }); it('opens DevTools and emits event', () => { devTools.open(); expect(devTools.isOpen).toBe(true); expect(devTools.overlay.style.display).toBe('block'); expect(Core.emit).toHaveBeenCalledWith('devtools:opened'); }); it('closes DevTools and emits event', () => { devTools.open(); devTools.close(); expect(devTools.isOpen).toBe(false); expect(devTools.overlay.style.display).toBe('none'); expect(Core.emit).toHaveBeenCalledWith('devtools:closed'); }); it('toggles DevTools visibility', () => { expect(devTools.isOpen).toBe(false); devTools.toggle(); expect(devTools.isOpen).toBe(true); devTools.toggle(); expect(devTools.isOpen).toBe(false); }); it('minimizes and unminimizes DevTools', () => { devTools.open(); devTools.toggleMinimize(); expect(devTools.overlay.classList.contains('lc-devtools--minimized')).toBe(true); devTools.toggleMinimize(); expect(devTools.overlay.classList.contains('lc-devtools--minimized')).toBe(false); }); }); describe('Keyboard Shortcuts', () => { beforeEach(() => { devTools = new LiveComponentDevTools(); }); it('toggles DevTools with Ctrl+Shift+D', () => { const event = new KeyboardEvent('keydown', { key: 'D', ctrlKey: true, shiftKey: true }); document.dispatchEvent(event); expect(devTools.isOpen).toBe(true); }); it('toggles DevTools with Cmd+Shift+D on Mac', () => { const event = new KeyboardEvent('keydown', { key: 'D', metaKey: true, // Cmd key on Mac shiftKey: true }); document.dispatchEvent(event); expect(devTools.isOpen).toBe(true); }); }); describe('DOM Badges', () => { beforeEach(() => { devTools = new LiveComponentDevTools(); }); it('creates DOM badges for components', () => { document.body.innerHTML = `
Counter
`; devTools.updateDomBadges(); expect(devTools.domBadges.size).toBe(1); expect(devTools.domBadges.has('counter:demo')).toBe(true); const badge = document.querySelector('.lc-dom-badge[data-component-id="counter:demo"]'); expect(badge).not.toBeNull(); expect(badge.textContent).toContain('Counter'); }); it('updates badge activity counter', () => { document.body.innerHTML = `
`; devTools.updateDomBadges(); devTools.updateBadgeActivity('counter:demo'); const badgeData = devTools.domBadges.get('counter:demo'); expect(badgeData.actionCount).toBe(1); const actionsSpan = badgeData.badge.querySelector('.lc-badge-actions'); expect(actionsSpan.textContent).toBe('1 action'); }); it('pluralizes action count correctly', () => { document.body.innerHTML = `
`; devTools.updateDomBadges(); devTools.updateBadgeActivity('counter:demo'); devTools.updateBadgeActivity('counter:demo'); const actionsSpan = devTools.domBadges.get('counter:demo').badge.querySelector('.lc-badge-actions'); expect(actionsSpan.textContent).toBe('2 actions'); }); it('toggles badge visibility', () => { document.body.innerHTML = `
`; devTools.updateDomBadges(); const badgeData = devTools.domBadges.get('counter:demo'); expect(badgeData.badge.style.display).not.toBe('none'); devTools.toggleBadges(); expect(badgeData.badge.style.display).toBe('none'); devTools.toggleBadges(); expect(badgeData.badge.style.display).toBe('block'); }); it('cleans up badges for removed components', () => { document.body.innerHTML = `
`; devTools.updateDomBadges(); expect(devTools.domBadges.size).toBe(1); // Remove component from DOM document.body.innerHTML = ''; devTools.cleanupRemovedBadges(); expect(devTools.domBadges.size).toBe(0); }); }); describe('Performance Profiling', () => { beforeEach(() => { devTools = new LiveComponentDevTools(); }); it('starts performance recording', () => { devTools.startPerformanceRecording(); expect(devTools.isRecording).toBe(true); expect(devTools.performanceRecording).toEqual([]); expect(devTools.memorySnapshots.length).toBeGreaterThan(0); // Initial snapshot }); it('stops performance recording', () => { devTools.startPerformanceRecording(); devTools.stopPerformanceRecording(); expect(devTools.isRecording).toBe(false); }); it('records component renders', () => { devTools.isRecording = true; devTools.recordComponentRender('counter:demo', 25, 100, 125); expect(devTools.performanceRecording.length).toBe(1); expect(devTools.performanceRecording[0].type).toBe('render'); expect(devTools.performanceRecording[0].componentId).toBe('counter:demo'); expect(devTools.performanceRecording[0].duration).toBe(25); }); it('does not record when not recording', () => { devTools.isRecording = false; devTools.recordComponentRender('counter:demo', 25, 100, 125); expect(devTools.performanceRecording.length).toBe(0); }); it('takes memory snapshots', () => { devTools.takeMemorySnapshot(); expect(devTools.memorySnapshots.length).toBe(1); const snapshot = devTools.memorySnapshots[0]; expect(snapshot.usedJSHeapSize).toBeDefined(); expect(snapshot.totalJSHeapSize).toBeDefined(); expect(snapshot.jsHeapSizeLimit).toBeDefined(); expect(snapshot.timestamp).toBeDefined(); }); it('maintains max 100 performance recordings', () => { devTools.isRecording = true; for (let i = 0; i < 110; i++) { devTools.recordComponentRender(`component:${i}`, 10, 0, 10); } expect(devTools.performanceRecording.length).toBe(100); }); it('maintains max 100 memory snapshots', () => { for (let i = 0; i < 110; i++) { devTools.takeMemorySnapshot(); } expect(devTools.memorySnapshots.length).toBe(100); }); }); describe('Clear Operations', () => { beforeEach(() => { devTools = new LiveComponentDevTools(); }); it('clears action log', () => { devTools.logAction('component:1', 'action', {}, 0, 10, true); devTools.logAction('component:2', 'action', {}, 0, 10, true); expect(devTools.actionLog.length).toBe(2); devTools.clearActionLog(); expect(devTools.actionLog.length).toBe(0); }); it('clears event log', () => { devTools.logEvent('event:1', {}); devTools.logEvent('event:2', {}); expect(devTools.eventLog.length).toBe(2); devTools.clearEventLog(); expect(devTools.eventLog.length).toBe(0); }); it('clears network log', () => { devTools.networkLog = [ { timestamp: Date.now(), method: 'GET', url: '/api/users', status: 200, duration: 50 }, { timestamp: Date.now(), method: 'POST', url: '/api/posts', status: 201, duration: 100 } ]; expect(devTools.networkLog.length).toBe(2); devTools.clearNetworkLog(); expect(devTools.networkLog.length).toBe(0); }); it('clears performance data', () => { devTools.performanceRecording = [{ type: 'action', duration: 10 }]; devTools.componentRenderTimes.set('component:1', [10, 20]); devTools.actionExecutionTimes.set('component:1:action', [5, 10]); devTools.memorySnapshots = [{ timestamp: Date.now() }]; devTools.clearPerformanceData(); expect(devTools.performanceRecording.length).toBe(0); expect(devTools.componentRenderTimes.size).toBe(0); expect(devTools.actionExecutionTimes.size).toBe(0); expect(devTools.memorySnapshots.length).toBe(0); }); }); describe('Utility Methods', () => { beforeEach(() => { devTools = new LiveComponentDevTools(); }); it('formats bytes to human-readable string', () => { expect(devTools.formatBytes(0)).toBe('0 B'); expect(devTools.formatBytes(1024)).toBe('1 KB'); expect(devTools.formatBytes(1048576)).toBe('1 MB'); expect(devTools.formatBytes(1073741824)).toBe('1 GB'); expect(devTools.formatBytes(1536)).toBe('1.5 KB'); }); it('checks if enabled in development mode', () => { document.documentElement.dataset.env = 'development'; expect(devTools.checkIfEnabled()).toBe(true); }); it('checks if enabled with localStorage override', () => { document.documentElement.dataset.env = 'production'; mockLocalStorage['livecomponent_devtools'] = 'true'; expect(devTools.checkIfEnabled()).toBe(true); }); it('is disabled in production without override', () => { document.documentElement.dataset.env = 'production'; expect(devTools.checkIfEnabled()).toBe(false); }); }); });