Files
michaelschiemer/tests/Unit/Framework/LiveComponents/Observability/LiveComponentDevTools.test.js
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

713 lines
24 KiB
JavaScript

/**
* @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 = `
<div data-component-id="counter:demo" data-component-name="Counter">
<span>Count: 0</span>
</div>
<div data-component-id="user-stats:123" data-component-name="UserStats">
<p>Stats</p>
</div>
`;
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 = `
<div
data-component-id="product:5"
data-component-name="Product"
data-product-id="5"
data-category="electronics"
>
Product
</div>
`;
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 = `
<div data-component-id="anonymous:1">Content</div>
`;
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 = `
<div data-component-id="counter:demo" data-component-name="Counter">
Counter
</div>
`;
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 = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
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 = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
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 = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
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 = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
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);
});
});
});