/** * Tests for ServiceWorkerManager */ import { ServiceWorkerManager } from '../ServiceWorkerManager'; import { ConversionIQConfig } from '../../types'; describe('ServiceWorkerManager', () => { let manager: ServiceWorkerManager; let config: ConversionIQConfig; let mockRegistration: any; let mockServiceWorker: any; beforeEach(() => { config = { apiKey: 'test-key', websiteId: 'test-website', endpoint: 'https://api.example.com', debug: false, enableOfflineQueue: true, maxOfflineEvents: 1000, syncInterval: 30000 }; // Mock ServiceWorker API mockServiceWorker = { register: jest.fn(), addEventListener: jest.fn() }; mockRegistration = { active: { postMessage: jest.fn() }, sync: { register: jest.fn().mockResolvedValue(undefined) } }; Object.defineProperty(navigator, 'serviceWorker', { writable: true, configurable: true, value: mockServiceWorker }); Object.defineProperty(window, 'PushManager', { writable: true, configurable: true, value: class PushManager {} }); // Mock MessageChannel for postMessage communication (global as any).MessageChannel = class { port1: any; port2: any; constructor() { this.port1 = { onmessage: null as any, postMessage: jest.fn() }; this.port2 = { onmessage: null as any, postMessage: jest.fn(), // Store reference to port1 for simulating responses _connectedPort: this.port1 }; } }; // Mock ServiceWorkerRegistration class (global as any).ServiceWorkerRegistration = { prototype: { sync: {} } }; }); afterEach(() => { jest.clearAllMocks(); }); describe('Initialization', () => { it('should initialize with valid config', () => { manager = new ServiceWorkerManager(config); expect(manager).toBeInstanceOf(ServiceWorkerManager); expect(manager.isServiceWorkerSupported()).toBe(true); }); it('should detect when service workers are not supported', () => { delete (navigator as any).serviceWorker; manager = new ServiceWorkerManager(config); expect(manager.isServiceWorkerSupported()).toBe(false); }); it('should use default config values', () => { const minimalConfig: ConversionIQConfig = { apiKey: 'test-key', websiteId: 'test-website', endpoint: 'https://api.example.com' }; manager = new ServiceWorkerManager(minimalConfig); expect(manager).toBeInstanceOf(ServiceWorkerManager); }); it('should register service worker on init', async () => { mockServiceWorker.register.mockResolvedValue(mockRegistration); manager = new ServiceWorkerManager(config); await manager.init(); expect(mockServiceWorker.register).toHaveBeenCalled(); expect(mockServiceWorker.register).toHaveBeenCalledWith( expect.stringContaining('/conversioniq-sw.js'), expect.objectContaining({ scope: '/', updateViaCache: 'none' }) ); }); it('should not register when offline queue is disabled', async () => { config.enableOfflineQueue = false; manager = new ServiceWorkerManager(config); await manager.init(); expect(mockServiceWorker.register).not.toHaveBeenCalled(); }); it('should handle registration failure gracefully', async () => { mockServiceWorker.register.mockRejectedValue(new Error('Registration failed')); manager = new ServiceWorkerManager(config); await manager.init(); // Should not throw expect(mockServiceWorker.register).toHaveBeenCalled(); }); it('should log debug messages when debug is enabled', async () => { const consoleLog = jest.spyOn(console, 'log').mockImplementation(); config.debug = true; mockServiceWorker.register.mockResolvedValue(mockRegistration); manager = new ServiceWorkerManager(config); await manager.init(); expect(consoleLog).toHaveBeenCalledWith( expect.stringContaining('Service Worker registered successfully') ); consoleLog.mockRestore(); }); it('should log warnings on registration failure in debug mode', async () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); config.debug = true; mockServiceWorker.register.mockRejectedValue(new Error('Failed')); manager = new ServiceWorkerManager(config); await manager.init(); expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining('Service Worker registration failed'), expect.any(Error) ); consoleWarn.mockRestore(); }); }); describe('Offline Queue', () => { beforeEach(async () => { mockServiceWorker.register.mockResolvedValue(mockRegistration); manager = new ServiceWorkerManager(config); await manager.init(); }); it('should queue offline events', async () => { const event = { id: 'evt-123', type: 'page_view', timestamp: Date.now(), priority: 'normal' as const, payload: { url: '/test' } }; // Mock the service worker response mockRegistration.active.postMessage.mockImplementation((message: any, ports: any[]) => { // Simulate async response from service worker via MessageChannel setImmediate(() => { const port2 = ports[0]; // Trigger response on the connected port1 if (port2 && port2._connectedPort && port2._connectedPort.onmessage) { port2._connectedPort.onmessage({ data: { success: true } }); } }); }); await manager.queueOfflineEvent(event); expect(mockRegistration.active.postMessage).toHaveBeenCalledWith( { type: 'QUEUE_OFFLINE_EVENT', payload: event }, expect.any(Array) ); }); it('should not queue when service worker is not registered', async () => { manager = new ServiceWorkerManager(config); // Don't call init() const event = { id: 'evt-123', type: 'page_view', timestamp: Date.now(), priority: 'normal' as const, payload: {} }; await manager.queueOfflineEvent(event); // Should not throw and should not call postMessage expect(mockRegistration.active.postMessage).not.toHaveBeenCalled(); }); it('should handle queue errors gracefully', async () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); config.debug = true; mockRegistration.active.postMessage.mockImplementation(() => { throw new Error('Queue failed'); }); manager = new ServiceWorkerManager(config); await manager.init(); const event = { id: 'evt-123', type: 'page_view', timestamp: Date.now(), priority: 'normal' as const, payload: {} }; await manager.queueOfflineEvent(event); expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining('Failed to queue offline event'), expect.any(Error) ); consoleWarn.mockRestore(); }); it('should get offline stats', async () => { const stats = await manager.getOfflineStats(); expect(stats).toHaveProperty('queueSize'); expect(stats).toHaveProperty('lastSync'); }); it('should return empty stats when not supported', async () => { delete (navigator as any).serviceWorker; manager = new ServiceWorkerManager(config); const stats = await manager.getOfflineStats(); expect(stats).toEqual({ queueSize: 0, lastSync: 0 }); }); it('should clear offline queue', async () => { // Mock the service worker response mockRegistration.active.postMessage.mockImplementation((message: any, ports: any[]) => { // Simulate async response from service worker via MessageChannel setImmediate(() => { const port2 = ports[0]; // Trigger response on the connected port1 if (port2 && port2._connectedPort && port2._connectedPort.onmessage) { port2._connectedPort.onmessage({ data: { success: true } }); } }); }); await manager.clearOfflineQueue(); expect(mockRegistration.active.postMessage).toHaveBeenCalledWith( { type: 'CLEAR_OFFLINE_QUEUE' }, expect.any(Array) ); }); it('should handle clear queue errors', async () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); config.debug = true; mockRegistration.active.postMessage.mockImplementation(() => { throw new Error('Clear failed'); }); manager = new ServiceWorkerManager(config); await manager.init(); await manager.clearOfflineQueue(); expect(consoleWarn).toHaveBeenCalled(); consoleWarn.mockRestore(); }); }); describe('Background Sync', () => { beforeEach(async () => { mockServiceWorker.register.mockResolvedValue(mockRegistration); manager = new ServiceWorkerManager(config); await manager.init(); }); it('should trigger background sync', async () => { await manager.triggerSync(); expect(mockRegistration.sync.register).toHaveBeenCalledWith('conversioniq-sync'); }); it('should not trigger sync when not supported', async () => { delete mockRegistration.sync; manager = new ServiceWorkerManager(config); await manager.init(); await manager.triggerSync(); // Should not throw }); it('should handle sync registration errors', async () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); config.debug = true; mockRegistration.sync.register.mockRejectedValue(new Error('Sync failed')); manager = new ServiceWorkerManager(config); await manager.init(); await manager.triggerSync(); expect(consoleWarn).toHaveBeenCalled(); consoleWarn.mockRestore(); }); }); describe('Message Handling', () => { beforeEach(async () => { mockServiceWorker.register.mockResolvedValue(mockRegistration); manager = new ServiceWorkerManager(config); await manager.init(); }); it('should setup message handlers', () => { expect(mockServiceWorker.addEventListener).toHaveBeenCalledWith( 'message', expect.any(Function) ); }); it('should handle SYNC_COMPLETED messages in debug mode', () => { const consoleLog = jest.spyOn(console, 'log').mockImplementation(); config.debug = true; manager = new ServiceWorkerManager(config); // Get the message handler const messageHandler = mockServiceWorker.addEventListener.mock.calls.find( (call: any[]) => call[0] === 'message' )?.[1]; if (messageHandler) { messageHandler({ data: { type: 'SYNC_COMPLETED', payload: { eventCount: 5 } } }); expect(consoleLog).toHaveBeenCalledWith( expect.stringContaining('Background sync completed'), expect.any(Object) ); } consoleLog.mockRestore(); }); it('should handle SYNC_FAILED messages in debug mode', () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); config.debug = true; manager = new ServiceWorkerManager(config); const messageHandler = mockServiceWorker.addEventListener.mock.calls.find( (call: any[]) => call[0] === 'message' )?.[1]; if (messageHandler) { messageHandler({ data: { type: 'SYNC_FAILED', payload: { error: 'Network error' } } }); expect(consoleWarn).toHaveBeenCalled(); } consoleWarn.mockRestore(); }); }); describe('Service Worker Code Generation', () => { it('should generate service worker code', () => { const swCode = ServiceWorkerManager.generateServiceWorkerCode(config); expect(swCode).toContain('ConversionIQ Service Worker'); expect(swCode).toContain('conversioniq-sync'); expect(swCode).toContain(config.endpoint); expect(swCode).toContain('1000'); // maxOfflineEvents }); it('should use default values in generated code', () => { const swCode = ServiceWorkerManager.generateServiceWorkerCode(); expect(swCode).toContain('ConversionIQ Service Worker'); expect(swCode).toContain('https://api.conversioniq.com'); }); it('should include event handlers in generated code', () => { const swCode = ServiceWorkerManager.generateServiceWorkerCode(config); expect(swCode).toContain('addEventListener(\'install\''); expect(swCode).toContain('addEventListener(\'activate\''); expect(swCode).toContain('addEventListener(\'sync\''); expect(swCode).toContain('addEventListener(\'message\''); }); it('should include queue management functions', () => { const swCode = ServiceWorkerManager.generateServiceWorkerCode(config); expect(swCode).toContain('queueOfflineEvent'); expect(swCode).toContain('syncOfflineEvents'); expect(swCode).toContain('getOfflineStats'); expect(swCode).toContain('clearOfflineQueue'); }); }); describe('Cleanup', () => { it('should unregister service worker on destroy', async () => { mockRegistration.unregister = jest.fn().mockResolvedValue(true); mockServiceWorker.register.mockResolvedValue(mockRegistration); manager = new ServiceWorkerManager(config); await manager.init(); await manager.destroy(); expect(mockRegistration.unregister).toHaveBeenCalled(); }); it('should handle unregister errors', async () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); config.debug = true; mockRegistration.unregister = jest.fn().mockRejectedValue(new Error('Unregister failed')); mockServiceWorker.register.mockResolvedValue(mockRegistration); manager = new ServiceWorkerManager(config); await manager.init(); await manager.destroy(); expect(consoleWarn).toHaveBeenCalled(); consoleWarn.mockRestore(); }); it('should handle destroy when not registered', async () => { manager = new ServiceWorkerManager(config); await manager.destroy(); // Should not throw }); }); describe('Feature Detection', () => { it('should detect service worker support', () => { manager = new ServiceWorkerManager(config); expect(manager.isServiceWorkerSupported()).toBe(true); }); it('should detect lack of service worker support', () => { delete (navigator as any).serviceWorker; manager = new ServiceWorkerManager(config); expect(manager.isServiceWorkerSupported()).toBe(false); }); it('should detect lack of PushManager', () => { delete (window as any).PushManager; manager = new ServiceWorkerManager(config); expect(manager.isServiceWorkerSupported()).toBe(false); }); }); });