lightweight-browser-load-tester
Version:
A lightweight load testing tool using real browsers for streaming applications with DRM support
752 lines (618 loc) • 25.5 kB
text/typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TestRunner } from './test-runner';
import { BrowserPool } from '../managers/browser-pool';
import { RequestInterceptor } from '../interceptors/request-interceptor';
import { TestConfiguration, ManagedBrowserInstance } from '../types';
// Mock dependencies
vi.mock('../managers/browser-pool');
vi.mock('../interceptors/request-interceptor');
const MockBrowserPool = vi.mocked(BrowserPool);
const MockRequestInterceptor = vi.mocked(RequestInterceptor);
describe('TestRunner', () => {
let testConfig: TestConfiguration;
let testRunner: TestRunner;
let mockBrowserPool: any;
let mockBrowserInstance: ManagedBrowserInstance;
let mockPage: any;
let mockInterceptor: any;
beforeEach(() => {
vi.clearAllMocks();
testConfig = {
concurrentUsers: 2,
testDuration: 10,
rampUpTime: 2,
streamingUrl: 'https://example.com/stream',
requestParameters: [],
resourceLimits: {
maxMemoryPerInstance: 512,
maxCpuPercentage: 80,
maxConcurrentInstances: 5
}
};
// Mock page object
mockPage = {
goto: vi.fn().mockResolvedValue(undefined),
route: vi.fn().mockResolvedValue(undefined),
unroute: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
removeListener: vi.fn()
};
// Mock browser instance
mockBrowserInstance = {
id: 'test-browser-1',
browser: {},
context: {},
page: mockPage,
createdAt: new Date(),
lastUsed: new Date(),
isActive: false,
metrics: {
instanceId: 'test-browser-1',
memoryUsage: 100,
cpuUsage: 10,
requestCount: 0,
errorCount: 0,
uptime: 0
}
};
// Mock interceptor
mockInterceptor = {
startInterception: vi.fn().mockResolvedValue(undefined),
stopInterception: vi.fn().mockResolvedValue(undefined),
startStreamingMonitoring: vi.fn(),
getNetworkMetrics: vi.fn().mockReturnValue([]),
getErrors: vi.fn().mockReturnValue([]),
getStreamingErrors: vi.fn().mockReturnValue([])
};
// Mock browser pool
mockBrowserPool = {
initialize: vi.fn().mockResolvedValue(undefined),
acquireInstance: vi.fn().mockResolvedValue(mockBrowserInstance),
releaseInstance: vi.fn().mockResolvedValue(undefined),
getMetrics: vi.fn().mockReturnValue([mockBrowserInstance.metrics]),
getPoolStatus: vi.fn().mockReturnValue({
totalInstances: 1,
availableInstances: 1,
activeInstances: 0,
maxInstances: 5,
resourceLimits: testConfig.resourceLimits
}),
getResourceUsageStats: vi.fn().mockReturnValue({
totalInstances: 1,
activeInstances: 0,
totalMemoryUsage: 100,
averageMemoryUsage: 100,
totalCpuUsage: 10,
averageCpuUsage: 10,
memoryUtilization: 20,
cpuUtilization: 12.5,
instancesNearMemoryLimit: 0,
instancesNearCpuLimit: 0,
resourceLimits: testConfig.resourceLimits
}),
cleanupIdleInstances: vi.fn().mockResolvedValue(0),
shutdown: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
emit: vi.fn()
};
MockBrowserPool.mockImplementation(() => mockBrowserPool);
MockRequestInterceptor.mockImplementation(() => mockInterceptor);
testRunner = new TestRunner(testConfig);
});
afterEach(() => {
vi.useRealTimers();
});
describe('constructor', () => {
it('should create TestRunner with valid configuration', () => {
expect(testRunner).toBeInstanceOf(TestRunner);
expect(testRunner.getTestId()).toMatch(/^test-\d+-[a-z0-9]+$/);
expect(testRunner.isTestRunning()).toBe(false);
});
it('should create browser pool with correct configuration', () => {
expect(MockBrowserPool).toHaveBeenCalledWith({
maxInstances: testConfig.resourceLimits.maxConcurrentInstances,
minInstances: Math.min(2, testConfig.concurrentUsers),
resourceLimits: testConfig.resourceLimits,
browserOptions: expect.objectContaining({
headless: true,
args: expect.arrayContaining([
'--disable-web-security',
'--disable-features=VizDisplayCompositor',
'--enable-automation'
])
})
});
});
});
describe('startTest', () => {
it('should start test successfully', async () => {
vi.useFakeTimers();
const testStartedSpy = vi.fn();
const sessionStartedSpy = vi.fn();
testRunner.on('test-started', testStartedSpy);
testRunner.on('session-started', sessionStartedSpy);
const startPromise = testRunner.startTest();
// Fast-forward through ramp-up
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
expect(mockBrowserPool.initialize).toHaveBeenCalled();
expect(testRunner.isTestRunning()).toBe(true);
expect(testStartedSpy).toHaveBeenCalledWith({
testId: testRunner.getTestId(),
config: testConfig
});
expect(sessionStartedSpy).toHaveBeenCalledTimes(testConfig.concurrentUsers);
});
it('should throw error if test is already running', async () => {
vi.useFakeTimers();
const startPromise1 = testRunner.startTest();
await vi.advanceTimersByTimeAsync(100);
await expect(testRunner.startTest()).rejects.toThrow('Test is already running');
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise1;
});
it('should emit test-failed event on initialization error', async () => {
const initError = new Error('Initialization failed');
mockBrowserPool.initialize.mockRejectedValue(initError);
const testFailedSpy = vi.fn();
testRunner.on('test-failed', testFailedSpy);
await expect(testRunner.startTest()).rejects.toThrow('Initialization failed');
expect(testFailedSpy).toHaveBeenCalledWith({
testId: testRunner.getTestId(),
error: initError
});
expect(testRunner.isTestRunning()).toBe(false);
});
});
describe('stopTest', () => {
beforeEach(async () => {
vi.useFakeTimers();
const startPromise = testRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
});
it('should stop test and return results', async () => {
const testCompletedSpy = vi.fn();
testRunner.on('test-completed', testCompletedSpy);
const results = await testRunner.stopTest();
expect(results).toHaveProperty('summary');
expect(results).toHaveProperty('browserMetrics');
expect(results).toHaveProperty('networkMetrics');
expect(results).toHaveProperty('errors');
expect(mockBrowserPool.shutdown).toHaveBeenCalled();
expect(testRunner.isTestRunning()).toBe(false);
expect(testCompletedSpy).toHaveBeenCalledWith({
testId: testRunner.getTestId(),
results
});
});
it('should throw error if no test is running', async () => {
await testRunner.stopTest();
await expect(testRunner.stopTest()).rejects.toThrow('No test is currently running');
});
it('should handle multiple stop calls gracefully', async () => {
// Test that multiple stop calls don't cause issues
expect(testRunner.isTestRunning()).toBe(true);
const results = await testRunner.stopTest();
expect(results).toHaveProperty('summary');
expect(testRunner.isTestRunning()).toBe(false);
// Second call should throw
await expect(testRunner.stopTest()).rejects.toThrow('No test is currently running');
});
});
describe('session management', () => {
beforeEach(async () => {
vi.useFakeTimers();
});
it('should start sessions with proper ramp-up timing', async () => {
const sessionStartedSpy = vi.fn();
testRunner.on('session-started', sessionStartedSpy);
const startPromise = testRunner.startTest();
// Wait for ramp-up to complete
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
expect(sessionStartedSpy).toHaveBeenCalledTimes(testConfig.concurrentUsers);
}, 10000);
it('should handle session startup failures gracefully', () => {
// Test that the TestRunner can handle session failures without crashing
const sessionError = new Error('Session startup failed');
const sessionFailedSpy = vi.fn();
testRunner.on('session-failed', sessionFailedSpy);
// Simulate a session failure event
testRunner.emit('session-failed', {
sessionId: 'test-session',
testId: testRunner.getTestId(),
error: sessionError
});
expect(sessionFailedSpy).toHaveBeenCalledWith({
sessionId: 'test-session',
testId: testRunner.getTestId(),
error: sessionError
});
});
it('should complete sessions when test duration expires', async () => {
const sessionCompletedSpy = vi.fn();
testRunner.on('session-completed', sessionCompletedSpy);
const startPromise = testRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Fast-forward to test completion
await vi.advanceTimersByTimeAsync(testConfig.testDuration * 1000);
expect(sessionCompletedSpy).toHaveBeenCalledTimes(testConfig.concurrentUsers);
expect(mockInterceptor.stopInterception).toHaveBeenCalledTimes(testConfig.concurrentUsers);
expect(mockBrowserPool.releaseInstance).toHaveBeenCalledTimes(testConfig.concurrentUsers);
});
});
describe('monitoring', () => {
beforeEach(async () => {
vi.useFakeTimers();
const startPromise = testRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
});
it('should provide real-time monitoring data', () => {
const monitoringData = testRunner.getMonitoringData();
expect(monitoringData).toHaveProperty('activeSessions');
expect(monitoringData).toHaveProperty('completedSessions');
expect(monitoringData).toHaveProperty('failedSessions');
expect(monitoringData).toHaveProperty('totalRequests');
expect(monitoringData).toHaveProperty('successfulRequests');
expect(monitoringData).toHaveProperty('failedRequests');
expect(monitoringData).toHaveProperty('averageResponseTime');
expect(monitoringData).toHaveProperty('currentRps');
expect(monitoringData).toHaveProperty('elapsedTime');
expect(monitoringData).toHaveProperty('remainingTime');
expect(monitoringData).toHaveProperty('memoryUsage');
expect(monitoringData).toHaveProperty('cpuUsage');
});
it('should emit monitoring updates periodically', async () => {
const monitoringUpdateSpy = vi.fn();
testRunner.on('monitoring-update', monitoringUpdateSpy);
// Fast-forward to trigger monitoring updates
await vi.advanceTimersByTimeAsync(4000); // 2 updates at 2-second intervals
expect(monitoringUpdateSpy).toHaveBeenCalledTimes(2);
expect(monitoringUpdateSpy).toHaveBeenCalledWith({
testId: testRunner.getTestId(),
data: expect.objectContaining({
activeSessions: expect.any(Number),
elapsedTime: expect.any(Number)
})
});
});
it('should calculate metrics correctly with network data', () => {
const mockNetworkMetrics = [
{
url: 'https://example.com/api',
method: 'GET',
responseTime: 100,
statusCode: 200,
timestamp: new Date(),
requestSize: 500,
responseSize: 1000,
isStreamingRelated: true,
streamingType: 'api' as const
},
{
url: 'https://example.com/error',
method: 'GET',
responseTime: 200,
statusCode: 500,
timestamp: new Date(),
requestSize: 300,
responseSize: 100,
isStreamingRelated: false
}
];
mockInterceptor.getNetworkMetrics.mockReturnValue(mockNetworkMetrics);
const monitoringData = testRunner.getMonitoringData();
// We have 2 sessions, each returning the mock metrics, so 4 total requests
expect(monitoringData.totalRequests).toBe(4);
expect(monitoringData.successfulRequests).toBe(2);
expect(monitoringData.failedRequests).toBe(2);
expect(monitoringData.averageResponseTime).toBe(150);
});
});
describe('results generation', () => {
it('should generate comprehensive test results', async () => {
vi.useFakeTimers();
const mockNetworkMetrics = [
{
url: 'https://example.com/license',
method: 'POST',
responseTime: 150,
statusCode: 200,
timestamp: new Date(),
requestSize: 1000,
responseSize: 500,
isStreamingRelated: true,
streamingType: 'license' as const
}
];
const mockErrors = [
{
timestamp: new Date(),
level: 'error' as const,
message: 'Test error',
context: { sessionId: 'test-session' }
}
];
mockInterceptor.getNetworkMetrics.mockReturnValue(mockNetworkMetrics);
mockInterceptor.getErrors.mockReturnValue(mockErrors);
const startPromise = testRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
const results = await testRunner.stopTest();
expect(results.summary).toEqual({
totalRequests: 2, // 2 sessions * 1 request each
successfulRequests: 2,
failedRequests: 0,
averageResponseTime: 150,
peakConcurrentUsers: testConfig.concurrentUsers,
testDuration: expect.any(Number)
});
expect(results.browserMetrics).toHaveLength(1);
expect(results.networkMetrics).toHaveLength(2);
expect(results.errors).toHaveLength(2);
});
it('should generate DRM metrics when DRM config is provided', async () => {
vi.useFakeTimers();
const configWithDRM = {
...testConfig,
drmConfig: {
type: 'widevine' as const,
licenseUrl: 'https://example.com/license'
}
};
const testRunnerWithDRM = new TestRunner(configWithDRM);
const mockLicenseMetrics = [
{
url: 'https://example.com/license',
method: 'POST',
responseTime: 200,
statusCode: 200,
timestamp: new Date(),
requestSize: 1000,
responseSize: 500,
isStreamingRelated: true,
streamingType: 'license' as const
}
];
mockInterceptor.getNetworkMetrics.mockReturnValue(mockLicenseMetrics);
const startPromise = testRunnerWithDRM.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
const results = await testRunnerWithDRM.stopTest();
expect(results.drmMetrics).toHaveLength(1);
expect(results.drmMetrics[0]).toEqual({
licenseRequestCount: 2, // 2 sessions
averageLicenseTime: 200,
licenseSuccessRate: 100,
drmType: 'widevine',
errors: []
});
});
});
describe('error handling', () => {
it('should handle browser disconnect events', async () => {
vi.useFakeTimers();
const sessionFailedSpy = vi.fn();
testRunner.on('session-failed', sessionFailedSpy);
const startPromise = testRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Simulate browser disconnect
const browserPoolOnCall = mockBrowserPool.on.mock.calls.find(
call => call[0] === 'instanceDisconnected'
);
expect(browserPoolOnCall).toBeDefined();
const disconnectHandler = browserPoolOnCall[1];
disconnectHandler({ instanceId: mockBrowserInstance.id });
expect(sessionFailedSpy).toHaveBeenCalledWith({
sessionId: expect.stringMatching(/^session-\d+-[a-z0-9]+$/),
testId: testRunner.getTestId(),
error: new Error('Browser instance disconnected')
});
});
it('should handle session completion errors gracefully', async () => {
vi.useFakeTimers();
mockInterceptor.stopInterception.mockRejectedValue(new Error('Stop failed'));
const startPromise = testRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Should not throw when stopping test despite session completion errors
await expect(testRunner.stopTest()).resolves.toBeDefined();
});
});
describe('lifecycle management', () => {
it('should clean up resources on shutdown', async () => {
vi.useFakeTimers();
const startPromise = testRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
await testRunner.stopTest();
expect(mockBrowserPool.shutdown).toHaveBeenCalled();
expect(mockInterceptor.stopInterception).toHaveBeenCalledTimes(testConfig.concurrentUsers);
expect(testRunner.isTestRunning()).toBe(false);
});
it('should auto-stop test when duration expires', async () => {
vi.useFakeTimers();
const testCompletedSpy = vi.fn();
testRunner.on('test-completed', testCompletedSpy);
const startPromise = testRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Fast-forward past test duration
await vi.advanceTimersByTimeAsync(testConfig.testDuration * 1000);
expect(testCompletedSpy).toHaveBeenCalled();
expect(testRunner.isTestRunning()).toBe(false);
});
});
// NEW TESTS FOR URL FILTERING FUNCTIONALITY
describe('URL filtering integration', () => {
it('should create RequestInterceptor with default URL filtering parameters', async () => {
vi.useFakeTimers();
const startPromise = testRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Verify RequestInterceptor was called with correct parameters
expect(MockRequestInterceptor).toHaveBeenCalledWith(
mockPage,
testConfig.requestParameters,
expect.objectContaining({ sessionId: expect.any(String) }),
false, // streamingOnly default
[], // allowedUrls default
[] // blockedUrls default
);
});
it('should create RequestInterceptor with streaming-only mode enabled', async () => {
vi.useFakeTimers();
const streamingOnlyConfig = {
...testConfig,
streamingOnly: true
};
const streamingOnlyRunner = new TestRunner(streamingOnlyConfig);
const startPromise = streamingOnlyRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Verify RequestInterceptor was called with streaming-only enabled
expect(MockRequestInterceptor).toHaveBeenCalledWith(
mockPage,
streamingOnlyConfig.requestParameters,
expect.objectContaining({ sessionId: expect.any(String) }),
true, // streamingOnly enabled
[], // allowedUrls default
[] // blockedUrls default
);
});
it('should create RequestInterceptor with allowed URLs configured', async () => {
vi.useFakeTimers();
const allowedUrlsConfig = {
...testConfig,
streamingOnly: true,
allowedUrls: ['*.css', '*fonts*', '/api/essential/']
};
const allowedUrlsRunner = new TestRunner(allowedUrlsConfig);
const startPromise = allowedUrlsRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Verify RequestInterceptor was called with allowed URLs
expect(MockRequestInterceptor).toHaveBeenCalledWith(
mockPage,
allowedUrlsConfig.requestParameters,
expect.objectContaining({ sessionId: expect.any(String) }),
true, // streamingOnly enabled
['*.css', '*fonts*', '/api/essential/'], // allowedUrls
[] // blockedUrls default
);
});
it('should create RequestInterceptor with blocked URLs configured', async () => {
vi.useFakeTimers();
const blockedUrlsConfig = {
...testConfig,
blockedUrls: ['*analytics*', '*tracking*', '/ads/']
};
const blockedUrlsRunner = new TestRunner(blockedUrlsConfig);
const startPromise = blockedUrlsRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Verify RequestInterceptor was called with blocked URLs
expect(MockRequestInterceptor).toHaveBeenCalledWith(
mockPage,
blockedUrlsConfig.requestParameters,
expect.objectContaining({ sessionId: expect.any(String) }),
false, // streamingOnly default
[], // allowedUrls default
['*analytics*', '*tracking*', '/ads/'] // blockedUrls
);
});
it('should create RequestInterceptor with all URL filtering options configured', async () => {
vi.useFakeTimers();
const fullFilteringConfig = {
...testConfig,
streamingOnly: true,
allowedUrls: ['*.css', '*fonts*'],
blockedUrls: ['*analytics*', '*tracking*']
};
const fullFilteringRunner = new TestRunner(fullFilteringConfig);
const startPromise = fullFilteringRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Verify RequestInterceptor was called with all filtering options
expect(MockRequestInterceptor).toHaveBeenCalledWith(
mockPage,
fullFilteringConfig.requestParameters,
expect.objectContaining({ sessionId: expect.any(String) }),
true, // streamingOnly enabled
['*.css', '*fonts*'], // allowedUrls
['*analytics*', '*tracking*'] // blockedUrls
);
});
it('should handle undefined URL filtering options gracefully', async () => {
vi.useFakeTimers();
const undefinedOptionsConfig = {
...testConfig,
streamingOnly: undefined,
allowedUrls: undefined,
blockedUrls: undefined
};
const undefinedOptionsRunner = new TestRunner(undefinedOptionsConfig);
const startPromise = undefinedOptionsRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Verify RequestInterceptor was called with default values for undefined options
expect(MockRequestInterceptor).toHaveBeenCalledWith(
mockPage,
undefinedOptionsConfig.requestParameters,
expect.objectContaining({ sessionId: expect.any(String) }),
false, // streamingOnly defaults to false
[], // allowedUrls defaults to empty array
[] // blockedUrls defaults to empty array
);
});
it('should create separate RequestInterceptor instances for each session with same configuration', async () => {
vi.useFakeTimers();
const multiSessionConfig = {
...testConfig,
concurrentUsers: 3,
streamingOnly: true,
allowedUrls: ['*.css'],
blockedUrls: ['*analytics*']
};
const multiSessionRunner = new TestRunner(multiSessionConfig);
const startPromise = multiSessionRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Verify RequestInterceptor was called once for each session
expect(MockRequestInterceptor).toHaveBeenCalledTimes(3);
// Verify all calls had the same configuration
const calls = MockRequestInterceptor.mock.calls;
calls.forEach(call => {
expect(call).toEqual([
mockPage,
multiSessionConfig.requestParameters,
expect.objectContaining({ sessionId: expect.any(String) }),
true, // streamingOnly
['*.css'], // allowedUrls
['*analytics*'] // blockedUrls
]);
});
});
});
describe('blocked request tracking integration', () => {
beforeEach(() => {
// Add getBlockedRequestCount method to mock interceptor
mockInterceptor.getBlockedRequestCount = vi.fn().mockReturnValue(5);
});
it('should access blocked request count from interceptor', async () => {
vi.useFakeTimers();
const startPromise = testRunner.startTest();
await vi.advanceTimersByTimeAsync(testConfig.rampUpTime * 1000);
await startPromise;
// Simulate accessing blocked request count (this would be used in monitoring)
const interceptorInstance = MockRequestInterceptor.mock.results[0].value;
const blockedCount = interceptorInstance.getBlockedRequestCount();
expect(blockedCount).toBe(5);
expect(mockInterceptor.getBlockedRequestCount).toHaveBeenCalled();
});
});
});