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
text/typescript
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();
});
});
});