UNPKG

lightweight-browser-load-tester

Version:

A lightweight load testing tool using real browsers for streaming applications with DRM support

587 lines (456 loc) 20 kB
import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest'; import { BrowserPool } from './browser-pool'; import { BrowserPoolConfig } from '../types'; import { chromium, Browser, BrowserContext, Page } from 'playwright'; // Mock Playwright vi.mock('playwright', () => ({ chromium: { launch: vi.fn() } })); describe('BrowserPool', () => { let browserPool: BrowserPool; let mockBrowser: Browser; let mockContext: BrowserContext; let mockPage: Page; let config: BrowserPoolConfig; beforeEach(() => { // Setup mocks mockPage = { goto: vi.fn().mockResolvedValue(undefined), } as any; mockContext = { newPage: vi.fn().mockResolvedValue(mockPage), clearCookies: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), pages: vi.fn().mockReturnValue([mockPage]) } as any; mockBrowser = { newContext: vi.fn().mockResolvedValue(mockContext), close: vi.fn().mockResolvedValue(undefined), contexts: vi.fn().mockReturnValue([mockContext]), on: vi.fn() } as any; (chromium.launch as Mock).mockResolvedValue(mockBrowser); config = { maxInstances: 5, minInstances: 2, resourceLimits: { maxMemoryPerInstance: 512, maxCpuPercentage: 80, maxConcurrentInstances: 10 }, browserOptions: { headless: true, args: ['--test-arg'] } }; }); afterEach(async () => { if (browserPool) { await browserPool.shutdown(); } vi.clearAllMocks(); }); describe('initialization', () => { it('should create browser pool with correct configuration', () => { browserPool = new BrowserPool(config); expect(browserPool).toBeInstanceOf(BrowserPool); }); it('should initialize with minimum number of instances', async () => { browserPool = new BrowserPool(config); const initPromise = browserPool.initialize(); // Wait for initialization await initPromise; expect(chromium.launch).toHaveBeenCalledTimes(config.minInstances); expect(chromium.launch).toHaveBeenCalledWith({ headless: true, args: expect.arrayContaining(['--test-arg', '--no-sandbox']) }); }); it('should emit initialized event after setup', async () => { browserPool = new BrowserPool(config); const initSpy = vi.fn(); browserPool.on('initialized', initSpy); await browserPool.initialize(); expect(initSpy).toHaveBeenCalledWith({ instanceCount: config.minInstances }); }); }); describe('instance management', () => { beforeEach(async () => { browserPool = new BrowserPool(config); await browserPool.initialize(); }); it('should acquire available instance from pool', async () => { const instance = await browserPool.acquireInstance(); expect(instance).toBeDefined(); expect(instance.id).toMatch(/^browser-\d+-[a-z0-9]+$/); expect(instance.browser).toBe(mockBrowser); expect(instance.context).toBe(mockContext); expect(instance.page).toBe(mockPage); expect(instance.isActive).toBe(true); }); it('should create new instance when pool is empty and under limit', async () => { // Acquire all available instances const instances = []; for (let i = 0; i < config.minInstances; i++) { instances.push(await browserPool.acquireInstance()); } // Should create a new instance since we're under maxInstances const newInstance = await browserPool.acquireInstance(); expect(newInstance).toBeDefined(); expect(chromium.launch).toHaveBeenCalledTimes(config.minInstances + 1); }); it('should release instance back to pool', async () => { const instance = await browserPool.acquireInstance(); const instanceId = instance.id; await browserPool.releaseInstance(instanceId); expect(instance.isActive).toBe(false); expect(mockPage.goto).toHaveBeenCalledWith('about:blank'); expect(mockContext.clearCookies).toHaveBeenCalled(); }); it('should destroy instance if cleanup fails during release', async () => { const instance = await browserPool.acquireInstance(); const instanceId = instance.id; // Make cleanup fail mockPage.goto.mockRejectedValue(new Error('Navigation failed')); await browserPool.releaseInstance(instanceId); expect(mockContext.close).toHaveBeenCalled(); expect(mockBrowser.close).toHaveBeenCalled(); }); it('should throw error when releasing non-existent instance', async () => { await expect(browserPool.releaseInstance('non-existent')).rejects.toThrow( 'Browser instance non-existent not found' ); }); it('should throw error when acquiring instance during shutdown', async () => { const shutdownPromise = browserPool.shutdown(); await expect(browserPool.acquireInstance()).rejects.toThrow( 'Browser pool is shutting down' ); await shutdownPromise; }); }); describe('resource monitoring', () => { beforeEach(async () => { browserPool = new BrowserPool(config); await browserPool.initialize(); }); it('should return metrics for all instances', async () => { const _instance = await browserPool.acquireInstance(); const metrics = browserPool.getMetrics(); expect(metrics).toHaveLength(config.minInstances); expect(metrics[0]).toMatchObject({ instanceId: expect.any(String), memoryUsage: expect.any(Number), cpuUsage: expect.any(Number), requestCount: 0, errorCount: 0, uptime: expect.any(Number) }); }); it('should return correct pool status', async () => { const _instance = await browserPool.acquireInstance(); const status = browserPool.getPoolStatus(); expect(status).toEqual({ totalInstances: config.minInstances, availableInstances: config.minInstances - 1, activeInstances: 1, maxInstances: config.maxInstances, resourceLimits: config.resourceLimits }); }); it('should emit resource limit exceeded event', async () => { const limitSpy = vi.fn(); browserPool.on('resourceLimitExceeded', limitSpy); // Simulate high memory usage by modifying the internal method const instance = await browserPool.acquireInstance(); // Access private method for testing const updateMetrics = (browserPool as any).updateResourceMetrics.bind(browserPool); // Mock getBrowserMemoryUsage to return high value const originalGetMemory = (browserPool as any).getBrowserMemoryUsage; (browserPool as any).getBrowserMemoryUsage = vi.fn().mockResolvedValue(1000); // Exceeds 512MB limit await updateMetrics(); expect(limitSpy).toHaveBeenCalledWith({ instanceId: instance.id, type: 'memory', usage: 1000, limit: 512 }); // Restore original method (browserPool as any).getBrowserMemoryUsage = originalGetMemory; }); }); describe('error handling', () => { beforeEach(async () => { browserPool = new BrowserPool(config); }); it('should handle browser creation failure', async () => { (chromium.launch as Mock).mockRejectedValue(new Error('Launch failed')); await expect(browserPool.initialize()).rejects.toThrow('Launch failed'); }); it('should handle browser disconnect', async () => { await browserPool.initialize(); const disconnectSpy = vi.fn(); browserPool.on('instanceDisconnected', disconnectSpy); // Simulate browser disconnect const onCall = (mockBrowser.on as Mock).mock.calls.find(call => call[0] === 'disconnected'); if (onCall) { onCall[1](); // Call the disconnect handler } expect(disconnectSpy).toHaveBeenCalled(); }); it('should handle metrics update failure gracefully', async () => { await browserPool.initialize(); const errorSpy = vi.fn(); browserPool.on('metricsUpdateFailed', errorSpy); // Mock getBrowserMemoryUsage to throw error const originalGetMemory = (browserPool as any).getBrowserMemoryUsage; (browserPool as any).getBrowserMemoryUsage = vi.fn().mockRejectedValue(new Error('Memory check failed')); // Trigger metrics update await (browserPool as any).updateResourceMetrics(); expect(errorSpy).toHaveBeenCalled(); // Restore original method (browserPool as any).getBrowserMemoryUsage = originalGetMemory; }); }); describe('shutdown', () => { beforeEach(async () => { browserPool = new BrowserPool(config); await browserPool.initialize(); }); it('should shutdown all instances and cleanup resources', async () => { const shutdownSpy = vi.fn(); browserPool.on('shutdown', shutdownSpy); await browserPool.shutdown(); expect(mockContext.close).toHaveBeenCalledTimes(config.minInstances); expect(mockBrowser.close).toHaveBeenCalledTimes(config.minInstances); expect(shutdownSpy).toHaveBeenCalled(); }); it('should handle cleanup errors during shutdown', async () => { mockContext.close.mockRejectedValue(new Error('Close failed')); // Should not throw error await expect(browserPool.shutdown()).resolves.toBeUndefined(); }); }); describe('instance pooling and reuse', () => { beforeEach(async () => { browserPool = new BrowserPool(config); await browserPool.initialize(); }); it('should reuse released instances', async () => { // Acquire a single instance const instance1 = await browserPool.acquireInstance(); const instanceId = instance1.id; // Release the instance await browserPool.releaseInstance(instanceId); // Verify it's available for reuse const poolStatus = browserPool.getPoolStatus(); expect(poolStatus.availableInstances).toBeGreaterThan(0); // Acquire an instance - should reuse from available pool const reusedInstance = await browserPool.acquireInstance(); // Should get an instance (might be the same one or another available one) expect(reusedInstance).toBeDefined(); expect(reusedInstance.isActive).toBe(true); // The key test is that we're reusing instances from the pool, not always creating new ones // We can verify this by checking that we don't exceed the expected instance count const finalStatus = browserPool.getPoolStatus(); expect(finalStatus.totalInstances).toBeLessThanOrEqual(config.maxInstances); // Clean up await browserPool.releaseInstance(reusedInstance.id); }); it.skip('should wait for available instance when at max capacity', async () => { // Set a small max for testing const smallConfig = { ...config, maxInstances: 1, minInstances: 1 }; await browserPool.shutdown(); browserPool = new BrowserPool(smallConfig); await browserPool.initialize(); // Acquire the only instance const instance1 = await browserPool.acquireInstance(); // Verify we're at capacity const statusBeforeWait = browserPool.getPoolStatus(); expect(statusBeforeWait.availableInstances).toBe(0); expect(statusBeforeWait.totalInstances).toBe(1); // This should wait - start the acquire but don't await it yet const acquirePromise = browserPool.acquireInstance(); // Give it a moment to start waiting await new Promise(resolve => setTimeout(resolve, 100)); // Release the instance - this should trigger the waiting acquire await browserPool.releaseInstance(instance1.id); // Now the acquire should complete within a reasonable time const instance2 = await Promise.race([ acquirePromise, new Promise((_, reject) => setTimeout(() => reject(new Error('Acquire timed out')), 3000)) ]); expect(instance2).toBeDefined(); expect(instance2.isActive).toBe(true); // Clean up await browserPool.releaseInstance(instance2.id); }, 5000); it('should timeout when waiting too long for available instance', async () => { // Set a small max for testing const smallConfig = { ...config, maxInstances: 1, minInstances: 1 }; await browserPool.shutdown(); browserPool = new BrowserPool(smallConfig); await browserPool.initialize(); // Acquire the only instance await browserPool.acquireInstance(); // This should timeout (we'll mock setTimeout to make it faster) const originalSetTimeout = global.setTimeout; global.setTimeout = ((fn: Function) => { fn(); return 1 as any; }) as any; await expect(browserPool.acquireInstance()).rejects.toThrow( 'Timeout waiting for available browser instance' ); global.setTimeout = originalSetTimeout; }); }); describe('error recovery integration', () => { beforeEach(async () => { browserPool = new BrowserPool(config); await browserPool.initialize(); }); it('should emit circuit breaker events', async () => { const circuitOpenSpy = vi.fn(); const circuitClosedSpy = vi.fn(); browserPool.on('circuitBreakerOpened', circuitOpenSpy); browserPool.on('circuitBreakerClosed', circuitClosedSpy); // Get access to error recovery manager const errorRecovery = (browserPool as any).errorRecovery; // Simulate circuit breaker opening const instanceId = 'test-instance'; const error = new Error('Test error'); errorRecovery.recordFailure(instanceId, error); errorRecovery.recordFailure(instanceId, error); errorRecovery.recordFailure(instanceId, error); expect(circuitOpenSpy).toHaveBeenCalledWith({ instanceId }); }); it('should emit instance blacklisted events', async () => { const blacklistSpy = vi.fn(); browserPool.on('instanceBlacklisted', blacklistSpy); const errorRecovery = (browserPool as any).errorRecovery; const instanceId = 'test-instance'; const error = new Error('Test error'); // Generate enough failures to trigger blacklisting for (let i = 0; i < 6; i++) { errorRecovery.recordFailure(instanceId, error); } expect(blacklistSpy).toHaveBeenCalledWith({ instanceId, reason: 'Too many consecutive failures' }); }); it('should emit instance restart events', async () => { const restartAttemptedSpy = vi.fn(); browserPool.on('instanceRestartAttempted', restartAttemptedSpy); const errorRecovery = (browserPool as any).errorRecovery; const instanceId = 'test-instance'; errorRecovery.recordRestartAttempt(instanceId, true); expect(restartAttemptedSpy).toHaveBeenCalledWith({ instanceId, success: true, error: undefined }); }); it('should check circuit breaker state when acquiring instances', async () => { const errorRecovery = (browserPool as any).errorRecovery; const instance = await browserPool.acquireInstance(); const instanceId = instance.id; // Release the instance first await browserPool.releaseInstance(instanceId); // Open circuit breaker for this instance const error = new Error('Test error'); errorRecovery.recordFailure(instanceId, error); errorRecovery.recordFailure(instanceId, error); errorRecovery.recordFailure(instanceId, error); // Should skip this instance when acquiring const newInstance = await browserPool.acquireInstance(); expect(newInstance.id).not.toBe(instanceId); }); it('should attempt automatic restart on browser disconnect', async () => { const restartSpy = vi.fn(); const restartFailedSpy = vi.fn(); browserPool.on('instanceRestarted', restartSpy); browserPool.on('instanceRestartFailed', restartFailedSpy); const instance = await browserPool.acquireInstance(); const originalInstanceId = instance.id; // Simulate browser disconnect const onCall = (mockBrowser.on as Mock).mock.calls.find(call => call[0] === 'disconnected'); if (onCall) { onCall[1](); // Call the disconnect handler } // Wait a bit for async restart attempt await new Promise(resolve => setTimeout(resolve, 10)); // Should have attempted restart (success depends on mock setup) expect(restartSpy).toHaveBeenCalledWith({ originalInstanceId, newInstanceId: expect.any(String) }); }); it('should handle restart failure gracefully', async () => { const restartFailedSpy = vi.fn(); browserPool.on('instanceRestartFailed', restartFailedSpy); // Make browser creation fail for restart (chromium.launch as Mock).mockRejectedValueOnce(new Error('Restart failed')); const instance = await browserPool.acquireInstance(); const originalInstanceId = instance.id; // Simulate browser disconnect const onCall = (mockBrowser.on as Mock).mock.calls.find(call => call[0] === 'disconnected'); if (onCall) { onCall[1](); // Call the disconnect handler } // Wait a bit for async restart attempt await new Promise(resolve => setTimeout(resolve, 10)); expect(restartFailedSpy).toHaveBeenCalledWith({ instanceId: originalInstanceId, originalError: expect.any(Error), restartError: expect.any(Error) }); }); it('should provide error recovery statistics', async () => { const errorRecovery = (browserPool as any).errorRecovery; const instanceId = 'test-instance'; const error = new Error('Test error'); // Generate some failures and restarts errorRecovery.recordFailure(instanceId, error); errorRecovery.recordRestartAttempt(instanceId, true); const stats = browserPool.getErrorRecoveryStats(); expect(stats).toMatchObject({ totalInstances: expect.any(Number), failingInstances: expect.any(Number), blacklistedInstances: expect.any(Number), openCircuitBreakers: expect.any(Number), halfOpenCircuitBreakers: expect.any(Number), totalFailures: expect.any(Number), totalRestarts: expect.any(Number) }); }); it('should forward error logs from error recovery manager', async () => { const errorLoggedSpy = vi.fn(); browserPool.on('errorLogged', errorLoggedSpy); const errorRecovery = (browserPool as any).errorRecovery; const instanceId = 'test-instance'; const error = new Error('Test error'); errorRecovery.recordFailure(instanceId, error); expect(errorLoggedSpy).toHaveBeenCalledWith({ timestamp: expect.any(Date), level: 'error', message: 'Browser instance failure recorded', stack: error.stack, context: expect.objectContaining({ component: 'ErrorRecoveryManager', instanceId }) }); }); it('should shutdown error recovery manager on pool shutdown', async () => { const errorRecovery = (browserPool as any).errorRecovery; const shutdownSpy = vi.fn(); errorRecovery.on('shutdown', shutdownSpy); await browserPool.shutdown(); expect(shutdownSpy).toHaveBeenCalled(); }); }); });