/** * Tests for PerformanceMonitor */ import { PerformanceMonitor } from '../PerformanceMonitor'; import { ConversionIQConfig } from '../../types'; describe('PerformanceMonitor', () => { let monitor: PerformanceMonitor; let config: ConversionIQConfig; let mockPerformanceObserver: any; let performanceObserverCallbacks: Map void> = new Map(); beforeEach(() => { config = { apiKey: 'test-key', websiteId: 'test-website', endpoint: 'https://api.example.com', debug: false, enablePerformanceMonitoring: true }; performanceObserverCallbacks.clear(); // Mock performance.now() jest.spyOn(performance, 'now').mockReturnValue(1000); // Mock PerformanceObserver mockPerformanceObserver = jest.fn(function(this: any, callback: (list: any) => void) { this.observe = jest.fn((options: PerformanceObserverInit) => { // Store callback by entry type if (options.entryTypes && options.entryTypes.length > 0) { performanceObserverCallbacks.set(options.entryTypes[0], callback); } }); this.disconnect = jest.fn(); }); Object.defineProperty(window, 'PerformanceObserver', { writable: true, configurable: true, value: mockPerformanceObserver }); // Mock navigator.sendBeacon Object.defineProperty(navigator, 'sendBeacon', { writable: true, configurable: true, value: jest.fn(() => true) }); // Mock fetch global.fetch = jest.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response) ); jest.clearAllMocks(); }); afterEach(() => { if (monitor) { monitor.destroy(); } performanceObserverCallbacks.clear(); jest.restoreAllMocks(); }); describe('Initialization', () => { it('should initialize when PerformanceObserver is supported', () => { monitor = new PerformanceMonitor(config); expect(() => monitor.init()).not.toThrow(); }); it('should detect when PerformanceObserver is not supported', () => { delete (window as any).PerformanceObserver; monitor = new PerformanceMonitor(config); monitor.init(); // Should not throw, just skip initialization expect(monitor.getWebVitals()).toEqual({}); }); it('should log warning when PerformanceObserver not supported in debug mode', () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); delete (window as any).PerformanceObserver; config.debug = true; monitor = new PerformanceMonitor(config); monitor.init(); expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining('PerformanceObserver not supported') ); consoleWarn.mockRestore(); }); it('should setup multiple observers on init', () => { monitor = new PerformanceMonitor(config); monitor.init(); // Should create observers for: LCP, FID, FCP, CLS, navigation expect(mockPerformanceObserver).toHaveBeenCalled(); }); }); describe('Web Vitals Measurement', () => { beforeEach(() => { monitor = new PerformanceMonitor(config); monitor.init(); }); it('should measure LCP (Largest Contentful Paint)', () => { // Simulate LCP entry const mockEntry = { name: 'largest-contentful-paint', entryType: 'largest-contentful-paint', startTime: 0, duration: 0, renderTime: 2000, loadTime: 2100 }; // Trigger the observer callback const callback = performanceObserverCallbacks.get('largest-contentful-paint'); if (callback) { callback({ getEntries: () => [mockEntry] }); } const vitals = monitor.getWebVitals(); expect(vitals.LCP).toBeDefined(); expect(vitals.LCP?.name).toBe('LCP'); expect(vitals.LCP?.value).toBe(2000); expect(vitals.LCP?.rating).toBe('good'); // 2000ms is good }); it('should measure FID (First Input Delay)', () => { const mockEntry = { name: 'first-input', entryType: 'first-input', startTime: 1000, processingStart: 1050, duration: 0 }; const callback = performanceObserverCallbacks.get('first-input'); if (callback) { callback({ getEntries: () => [mockEntry] }); } const vitals = monitor.getWebVitals(); expect(vitals.FID).toBeDefined(); expect(vitals.FID?.value).toBe(50); // 1050 - 1000 expect(vitals.FID?.rating).toBe('good'); }); it('should measure FCP (First Contentful Paint)', () => { const mockEntry = { name: 'first-contentful-paint', entryType: 'paint', startTime: 1500, duration: 0 }; const callback = performanceObserverCallbacks.get('paint'); if (callback) { callback({ getEntries: () => [mockEntry] }); } const vitals = monitor.getWebVitals(); expect(vitals.FCP).toBeDefined(); expect(vitals.FCP?.value).toBe(1500); expect(vitals.FCP?.rating).toBe('good'); }); it('should measure CLS (Cumulative Layout Shift)', () => { const mockEntries = [ { name: 'layout-shift', entryType: 'layout-shift', startTime: 100, duration: 0, value: 0.05, hadRecentInput: false }, { name: 'layout-shift', entryType: 'layout-shift', startTime: 200, duration: 0, value: 0.03, hadRecentInput: false } ]; const callback = performanceObserverCallbacks.get('layout-shift'); if (callback) { callback({ getEntries: () => mockEntries }); } const vitals = monitor.getWebVitals(); expect(vitals.CLS).toBeDefined(); expect(vitals.CLS?.value).toBe(0.08); // 0.05 + 0.03 expect(vitals.CLS?.rating).toBe('good'); }); it('should ignore layout shifts with recent input', () => { const mockEntries = [ { name: 'layout-shift', entryType: 'layout-shift', startTime: 100, duration: 0, value: 0.05, hadRecentInput: false }, { name: 'layout-shift', entryType: 'layout-shift', startTime: 150, duration: 0, value: 0.10, hadRecentInput: true // Should be ignored } ]; const callback = performanceObserverCallbacks.get('layout-shift'); if (callback) { callback({ getEntries: () => mockEntries }); } const vitals = monitor.getWebVitals(); expect(vitals.CLS?.value).toBe(0.05); // Only the first shift }); it('should measure TTFB (Time to First Byte)', () => { const mockNavEntry = { name: 'navigation', entryType: 'navigation', startTime: 0, duration: 0, requestStart: 100, responseStart: 200, responseEnd: 250, domainLookupStart: 10, domainLookupEnd: 50, connectStart: 50, connectEnd: 90, domContentLoadedEventEnd: 500 } as PerformanceNavigationTiming; const callback = performanceObserverCallbacks.get('navigation'); if (callback) { callback({ getEntries: () => [mockNavEntry] }); } const vitals = monitor.getWebVitals(); expect(vitals.TTFB).toBeDefined(); expect(vitals.TTFB?.value).toBe(100); // 200 - 100 expect(vitals.TTFB?.rating).toBe('good'); }); }); describe('Performance Rating', () => { beforeEach(() => { monitor = new PerformanceMonitor(config); monitor.init(); }); it('should rate LCP as good when ≤2.5s', () => { const mockEntry = { name: 'largest-contentful-paint', entryType: 'largest-contentful-paint', startTime: 0, duration: 0, renderTime: 2000 }; const callback = performanceObserverCallbacks.get('largest-contentful-paint'); if (callback) { callback({ getEntries: () => [mockEntry] }); } expect(monitor.getWebVitals().LCP?.rating).toBe('good'); }); it('should rate LCP as needs-improvement when >2.5s and ≤4s', () => { const mockEntry = { name: 'largest-contentful-paint', entryType: 'largest-contentful-paint', startTime: 0, duration: 0, renderTime: 3000 }; const callback = performanceObserverCallbacks.get('largest-contentful-paint'); if (callback) { callback({ getEntries: () => [mockEntry] }); } expect(monitor.getWebVitals().LCP?.rating).toBe('needs-improvement'); }); it('should rate LCP as poor when >4s', () => { const mockEntry = { name: 'largest-contentful-paint', entryType: 'largest-contentful-paint', startTime: 0, duration: 0, renderTime: 5000 }; const callback = performanceObserverCallbacks.get('largest-contentful-paint'); if (callback) { callback({ getEntries: () => [mockEntry] }); } expect(monitor.getWebVitals().LCP?.rating).toBe('poor'); }); it('should rate CLS correctly', () => { // Good: ≤0.1 const goodEntry = { name: 'layout-shift', entryType: 'layout-shift', startTime: 100, duration: 0, value: 0.05, hadRecentInput: false }; const callback = performanceObserverCallbacks.get('layout-shift'); if (callback) { callback({ getEntries: () => [goodEntry] }); } expect(monitor.getWebVitals().CLS?.rating).toBe('good'); }); }); describe('SDK Performance Metrics', () => { beforeEach(() => { monitor = new PerformanceMonitor(config); }); it('should measure synchronous operations', () => { let callCount = 0; const result = monitor.measureSync('test_operation', () => { callCount++; return 42; }); expect(result).toBe(42); expect(callCount).toBe(1); const metrics = monitor.getSDKMetrics(); expect(metrics.test_operation).toBeDefined(); expect(typeof metrics.test_operation).toBe('number'); }); it('should measure async operations', async () => { let callCount = 0; const result = await monitor.measureAsync('test_async', async () => { callCount++; return Promise.resolve('success'); }); expect(result).toBe('success'); expect(callCount).toBe(1); const metrics = monitor.getSDKMetrics(); expect(metrics.test_async).toBeDefined(); }); it('should warn on slow synchronous operations in debug mode', () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); config.debug = true; monitor = new PerformanceMonitor(config); // Mock performance.now to simulate slow operation let callCount = 0; jest.spyOn(performance, 'now').mockImplementation(() => { callCount++; return callCount === 1 ? 0 : 10; // 10ms duration (> 5ms threshold) }); monitor.measureSync('slow_operation', () => { return 'done'; }); expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining('Slow operation slow_operation') ); consoleWarn.mockRestore(); }); it('should warn on slow async operations in debug mode', async () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); config.debug = true; monitor = new PerformanceMonitor(config); // Mock slow async operation (>10ms threshold) let callCount = 0; jest.spyOn(performance, 'now').mockImplementation(() => { callCount++; return callCount === 1 ? 0 : 15; }); await monitor.measureAsync('slow_async', async () => { return Promise.resolve('done'); }); expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining('Slow async operation slow_async') ); consoleWarn.mockRestore(); }); it('should track memory usage when available', () => { (performance as any).memory = { usedJSHeapSize: 1000000, totalJSHeapSize: 2000000, jsHeapSizeLimit: 4000000 }; monitor = new PerformanceMonitor(config); monitor.init(); const metrics = monitor.getSDKMetrics(); expect(metrics.memory_used).toBe(1000000); expect(metrics.memory_total).toBe(2000000); expect(metrics.memory_limit).toBe(4000000); }); }); describe('Performance Summary', () => { beforeEach(() => { monitor = new PerformanceMonitor(config); monitor.init(); }); it('should return performance summary with all metrics', () => { const summary = monitor.getPerformanceSummary(); expect(summary).toHaveProperty('webVitals'); expect(summary).toHaveProperty('sdkMetrics'); expect(summary).toHaveProperty('overallRating'); }); it('should calculate overall rating as good when all metrics are good', () => { // Simulate multiple good metrics const lcpCallback = performanceObserverCallbacks.get('largest-contentful-paint'); if (lcpCallback) { lcpCallback({ getEntries: () => [{ name: 'largest-contentful-paint', entryType: 'largest-contentful-paint', startTime: 0, duration: 0, renderTime: 2000 }] }); } const clsCallback = performanceObserverCallbacks.get('layout-shift'); if (clsCallback) { clsCallback({ getEntries: () => [{ name: 'layout-shift', entryType: 'layout-shift', startTime: 100, duration: 0, value: 0.05, hadRecentInput: false }] }); } const summary = monitor.getPerformanceSummary(); expect(summary.overallRating).toBe('good'); }); it('should calculate overall rating as poor when any metric is poor', () => { // Simulate poor LCP const lcpCallback = performanceObserverCallbacks.get('largest-contentful-paint'); if (lcpCallback) { lcpCallback({ getEntries: () => [{ name: 'largest-contentful-paint', entryType: 'largest-contentful-paint', startTime: 0, duration: 0, renderTime: 5000 // Poor rating }] }); } const summary = monitor.getPerformanceSummary(); expect(summary.overallRating).toBe('poor'); }); it('should calculate overall rating as needs-improvement when some metrics need improvement', () => { // Simulate needs-improvement LCP and good CLS const lcpCallback = performanceObserverCallbacks.get('largest-contentful-paint'); if (lcpCallback) { lcpCallback({ getEntries: () => [{ name: 'largest-contentful-paint', entryType: 'largest-contentful-paint', startTime: 0, duration: 0, renderTime: 3000 // Needs improvement }] }); } const clsCallback = performanceObserverCallbacks.get('layout-shift'); if (clsCallback) { clsCallback({ getEntries: () => [{ name: 'layout-shift', entryType: 'layout-shift', startTime: 100, duration: 0, value: 0.05, hadRecentInput: false }] }); } const summary = monitor.getPerformanceSummary(); expect(summary.overallRating).toBe('needs-improvement'); }); }); describe('Performance Reporting', () => { beforeEach(() => { monitor = new PerformanceMonitor(config); monitor.init(); }); it('should report performance using sendBeacon when available', async () => { const mockSendBeacon = jest.fn(() => true); Object.defineProperty(navigator, 'sendBeacon', { writable: true, value: mockSendBeacon }); await monitor.reportPerformance(); expect(mockSendBeacon).toHaveBeenCalled(); expect(mockSendBeacon).toHaveBeenCalledWith( 'https://api.example.com/api/v1/performance', expect.any(Blob) ); }); it('should fallback to fetch when sendBeacon is not available', async () => { delete (navigator as any).sendBeacon; const mockFetch = jest.fn(() => Promise.resolve({ ok: true } as Response)); global.fetch = mockFetch; await monitor.reportPerformance(); expect(mockFetch).toHaveBeenCalledWith( 'https://api.example.com/api/v1/performance', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true }) ); }); it('should not report when performance monitoring is disabled', async () => { config.enablePerformanceMonitoring = false; monitor = new PerformanceMonitor(config); const mockSendBeacon = jest.fn(); Object.defineProperty(navigator, 'sendBeacon', { writable: true, value: mockSendBeacon }); await monitor.reportPerformance(); expect(mockSendBeacon).not.toHaveBeenCalled(); }); it('should handle reporting errors gracefully', async () => { const mockSendBeacon = jest.fn(() => { throw new Error('Network error'); }); Object.defineProperty(navigator, 'sendBeacon', { writable: true, value: mockSendBeacon }); await expect(monitor.reportPerformance()).resolves.not.toThrow(); }); it('should log warning on reporting failure in debug mode', async () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); config.debug = true; monitor = new PerformanceMonitor(config); const mockSendBeacon = jest.fn(() => { throw new Error('Failed to send'); }); Object.defineProperty(navigator, 'sendBeacon', { writable: true, value: mockSendBeacon }); await monitor.reportPerformance(); expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining('Failed to report performance data'), expect.any(Error) ); consoleWarn.mockRestore(); }); it('should include all required fields in report payload', async () => { // Mock userAgent Object.defineProperty(navigator, 'userAgent', { writable: true, value: 'Mozilla/5.0 Test Browser' }); const mockSendBeacon = jest.fn(() => true); Object.defineProperty(navigator, 'sendBeacon', { writable: true, value: mockSendBeacon }); await monitor.reportPerformance(); const callArgs = mockSendBeacon.mock.calls[0]; const blob = callArgs[1] as Blob; // Read blob content using FileReader const text = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.readAsText(blob); }); const payload = JSON.parse(text); expect(payload).toHaveProperty('timestamp'); expect(payload).toHaveProperty('websiteId'); expect(payload).toHaveProperty('userAgent'); expect(payload).toHaveProperty('url'); expect(payload).toHaveProperty('webVitals'); expect(payload).toHaveProperty('sdkMetrics'); expect(payload).toHaveProperty('overallRating'); expect(payload.websiteId).toBe('test-website'); expect(payload.userAgent).toBe('Mozilla/5.0 Test Browser'); }); }); describe('Cleanup', () => { it('should disconnect all observers on destroy', () => { monitor = new PerformanceMonitor(config); monitor.init(); // Get the disconnect methods from created observers const observers = mockPerformanceObserver.mock.results.map( (result: any) => result.value ); monitor.destroy(); // Verify all observers were disconnected observers.forEach((observer: any) => { if (observer && observer.disconnect) { expect(observer.disconnect).toHaveBeenCalled(); } }); }); it('should clear vitals data on destroy', () => { monitor = new PerformanceMonitor(config); monitor.init(); // Add some vitals const lcpCallback = performanceObserverCallbacks.get('largest-contentful-paint'); if (lcpCallback) { lcpCallback({ getEntries: () => [{ name: 'largest-contentful-paint', entryType: 'largest-contentful-paint', startTime: 0, duration: 0, renderTime: 2000 }] }); } expect(Object.keys(monitor.getWebVitals()).length).toBeGreaterThan(0); monitor.destroy(); expect(monitor.getWebVitals()).toEqual({}); }); it('should clear SDK metrics on destroy', () => { monitor = new PerformanceMonitor(config); monitor.measureSync('test', () => 42); expect(Object.keys(monitor.getSDKMetrics()).length).toBeGreaterThan(0); monitor.destroy(); expect(monitor.getSDKMetrics()).toEqual({}); }); it('should handle observer disconnect errors gracefully', () => { monitor = new PerformanceMonitor(config); monitor.init(); // Mock observers to throw on disconnect const observers = mockPerformanceObserver.mock.results.map( (result: any) => result.value ); observers.forEach((observer: any) => { if (observer && observer.disconnect) { observer.disconnect.mockImplementation(() => { throw new Error('Disconnect failed'); }); } }); expect(() => monitor.destroy()).not.toThrow(); }); }); describe('Error Handling', () => { it('should handle observer creation errors', () => { mockPerformanceObserver.mockImplementation(() => { throw new Error('Observer creation failed'); }); monitor = new PerformanceMonitor(config); expect(() => monitor.init()).not.toThrow(); }); it('should log warning on observer creation failure in debug mode', () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); config.debug = true; mockPerformanceObserver.mockImplementation(function(this: any, callback: any) { this.observe = jest.fn((options: PerformanceObserverInit) => { throw new Error('Observe failed'); }); this.disconnect = jest.fn(); }); monitor = new PerformanceMonitor(config); monitor.init(); expect(consoleWarn).toHaveBeenCalled(); consoleWarn.mockRestore(); }); }); describe('Data Getters', () => { beforeEach(() => { monitor = new PerformanceMonitor(config); monitor.init(); }); it('should return a copy of web vitals data', () => { const vitals1 = monitor.getWebVitals(); const vitals2 = monitor.getWebVitals(); expect(vitals1).not.toBe(vitals2); // Different objects expect(vitals1).toEqual(vitals2); // Same data }); it('should return SDK metrics as plain object', () => { monitor.measureSync('test1', () => 1); monitor.measureSync('test2', () => 2); const metrics = monitor.getSDKMetrics(); expect(typeof metrics).toBe('object'); expect(Array.isArray(metrics)).toBe(false); expect(metrics.test1).toBeDefined(); expect(metrics.test2).toBeDefined(); }); }); });