UNPKG

a11yanalyze

Version:

A command-line tool for developers and QA engineers to test web pages and websites for WCAG 2.2 AA accessibility compliance

670 lines (542 loc) 21 kB
/** * Error resilience system unit tests * Tests circuit breakers, retry logic, adaptive timeouts, and resilience manager */ import { ErrorResilienceManager, CircuitBreaker, CircuitBreakerState, TimeoutManager, RetryManager, ResilienceConfig, RetryStrategy, } from './error-resilience'; describe('CircuitBreaker', () => { let circuitBreaker: CircuitBreaker; beforeEach(() => { circuitBreaker = new CircuitBreaker(3, 5000, 'test-circuit'); }); describe('Circuit States', () => { it('should start in CLOSED state', () => { expect(circuitBreaker.getState()).toBe(CircuitBreakerState.CLOSED); expect(circuitBreaker.getFailureCount()).toBe(0); }); it('should transition to OPEN after threshold failures', async () => { const failingOperation = jest.fn().mockRejectedValue(new Error('Test failure')); // Execute failing operations up to threshold for (let i = 0; i < 3; i++) { try { await circuitBreaker.execute(failingOperation); } catch (error) { // Expected to fail } } expect(circuitBreaker.getState()).toBe(CircuitBreakerState.OPEN); expect(circuitBreaker.getFailureCount()).toBe(3); }); it('should reject operations when OPEN', async () => { const failingOperation = jest.fn().mockRejectedValue(new Error('Test failure')); // Trip the circuit breaker for (let i = 0; i < 3; i++) { try { await circuitBreaker.execute(failingOperation); } catch (error) { // Expected to fail } } // Now circuit should be OPEN and reject new operations await expect(circuitBreaker.execute(() => Promise.resolve('success'))) .rejects.toThrow('Circuit breaker test-circuit is OPEN'); }); it('should transition to HALF_OPEN after timeout', async () => { jest.useFakeTimers(); const failingOperation = jest.fn().mockRejectedValue(new Error('Test failure')); // Trip the circuit breaker for (let i = 0; i < 3; i++) { try { await circuitBreaker.execute(failingOperation); } catch (error) { // Expected to fail } } expect(circuitBreaker.getState()).toBe(CircuitBreakerState.OPEN); // Fast-forward time beyond timeout jest.advanceTimersByTime(6000); // Next execution should transition to HALF_OPEN const successOperation = jest.fn().mockResolvedValue('success'); const result = await circuitBreaker.execute(successOperation); expect(result).toBe('success'); expect(circuitBreaker.getState()).toBe(CircuitBreakerState.CLOSED); expect(circuitBreaker.getFailureCount()).toBe(0); jest.useRealTimers(); }); it('should reset failure count on successful operation', async () => { const failingOperation = jest.fn().mockRejectedValue(new Error('Test failure')); const successOperation = jest.fn().mockResolvedValue('success'); // Partial failures try { await circuitBreaker.execute(failingOperation); } catch (error) { // Expected } expect(circuitBreaker.getFailureCount()).toBe(1); // Successful operation should reset count await circuitBreaker.execute(successOperation); expect(circuitBreaker.getFailureCount()).toBe(0); expect(circuitBreaker.getState()).toBe(CircuitBreakerState.CLOSED); }); it('should allow manual reset', async () => { const failingOperation = jest.fn().mockRejectedValue(new Error('Test failure')); // Trip the circuit breaker for (let i = 0; i < 3; i++) { try { await circuitBreaker.execute(failingOperation); } catch (error) { // Expected to fail } } expect(circuitBreaker.getState()).toBe(CircuitBreakerState.OPEN); // Manual reset circuitBreaker.reset(); expect(circuitBreaker.getState()).toBe(CircuitBreakerState.CLOSED); expect(circuitBreaker.getFailureCount()).toBe(0); }); }); }); describe('TimeoutManager', () => { let timeoutManager: TimeoutManager; let config: ResilienceConfig; beforeEach(() => { config = { maxRetries: 3, baseRetryDelay: 1000, maxRetryDelay: 10000, backoffMultiplier: 2, circuitBreakerThreshold: 5, circuitBreakerTimeout: 30000, operationTimeouts: { navigation: 30000, pageReady: 10000, ruleExecution: 15000, metadataExtraction: 5000, screenshot: 10000, }, adaptiveTimeouts: true, maxConcurrentOperations: 10, }; timeoutManager = new TimeoutManager(config); }); describe('Basic Timeout Handling', () => { it('should return base timeout when adaptive timeouts disabled', () => { config.adaptiveTimeouts = false; timeoutManager = new TimeoutManager(config); const timeout = timeoutManager.getTimeout('navigation'); expect(timeout).toBe(30000); }); it('should return base timeout when no metrics available', () => { const timeout = timeoutManager.getTimeout('navigation'); expect(timeout).toBe(30000); }); }); describe('Metrics Recording', () => { it('should record successful execution metrics', () => { timeoutManager.recordExecution('navigation', 2000, true, false); const metrics = timeoutManager.getMetrics(); const navigationMetrics = metrics.get('navigation'); expect(navigationMetrics).toMatchObject({ successCount: 1, failureCount: 0, averageExecutionTime: 2000, lastExecutionTime: 2000, timeouts: 0, }); }); it('should record failed execution metrics', () => { timeoutManager.recordExecution('navigation', 1500, false, true); const metrics = timeoutManager.getMetrics(); const navigationMetrics = metrics.get('navigation'); expect(navigationMetrics).toMatchObject({ successCount: 0, failureCount: 1, averageExecutionTime: 1500, lastExecutionTime: 1500, timeouts: 1, }); }); it('should calculate average execution time correctly', () => { timeoutManager.recordExecution('navigation', 1000, true, false); timeoutManager.recordExecution('navigation', 3000, true, false); const metrics = timeoutManager.getMetrics(); const navigationMetrics = metrics.get('navigation'); expect(navigationMetrics?.averageExecutionTime).toBe(2000); }); }); describe('Adaptive Timeout Calculation', () => { it('should increase timeout for high failure rate', () => { // Record several operations with low success rate for (let i = 0; i < 10; i++) { timeoutManager.recordExecution('navigation', 2000, i < 7, false); // 70% success rate } const adaptiveTimeout = timeoutManager.getTimeout('navigation'); expect(adaptiveTimeout).toBeGreaterThan(30000); // Should be increased }); it('should decrease timeout for consistently fast operations', () => { // Record many fast, successful operations for (let i = 0; i < 20; i++) { timeoutManager.recordExecution('navigation', 1000, true, false); } const adaptiveTimeout = timeoutManager.getTimeout('navigation'); expect(adaptiveTimeout).toBeLessThan(30000); // Should be decreased }); it('should not adapt with insufficient data', () => { // Record only a few operations timeoutManager.recordExecution('navigation', 1000, false, true); const adaptiveTimeout = timeoutManager.getTimeout('navigation'); expect(adaptiveTimeout).toBe(30000); // Should remain base timeout }); it('should respect minimum and maximum timeout bounds', () => { // Test maximum bound with very high failure rate for (let i = 0; i < 10; i++) { timeoutManager.recordExecution('navigation', 2000, false, true); } const maxTimeout = timeoutManager.getTimeout('navigation'); expect(maxTimeout).toBeLessThanOrEqual(90000); // Maximum 300% of base // Test minimum bound with very fast operations for (let i = 0; i < 30; i++) { timeoutManager.recordExecution('pageReady', 100, true, false); } const minTimeout = timeoutManager.getTimeout('pageReady'); expect(minTimeout).toBeGreaterThanOrEqual(5000); // Minimum 50% of base }); }); describe('Reset Functionality', () => { it('should clear all metrics on reset', () => { timeoutManager.recordExecution('navigation', 2000, true, false); timeoutManager.recordExecution('pageReady', 1000, false, true); expect(timeoutManager.getMetrics().size).toBe(2); timeoutManager.reset(); expect(timeoutManager.getMetrics().size).toBe(0); }); }); }); describe('RetryManager', () => { let retryManager: RetryManager; let config: ResilienceConfig; beforeEach(() => { config = { maxRetries: 3, baseRetryDelay: 100, maxRetryDelay: 1000, backoffMultiplier: 2, circuitBreakerThreshold: 5, circuitBreakerTimeout: 30000, operationTimeouts: { navigation: 30000, pageReady: 10000, ruleExecution: 15000, metadataExtraction: 5000, screenshot: 10000, }, adaptiveTimeouts: true, maxConcurrentOperations: 10, }; retryManager = new RetryManager(config); }); describe('Successful Retry Logic', () => { it('should succeed on first attempt', async () => { const operation = jest.fn().mockResolvedValue('success'); const result = await retryManager.executeWithRetry(operation, {}, 'test-operation'); expect(result).toBe('success'); expect(operation).toHaveBeenCalledTimes(1); }); it('should retry and eventually succeed', async () => { const operation = jest.fn() .mockRejectedValueOnce(new Error('timeout')) .mockRejectedValueOnce(new Error('network')) .mockResolvedValue('success'); const result = await retryManager.executeWithRetry( operation, { maxAttempts: 3 }, 'test-operation' ); expect(result).toBe('success'); expect(operation).toHaveBeenCalledTimes(3); }); it('should not retry non-retryable errors', async () => { const operation = jest.fn().mockRejectedValue(new Error('parsing error')); await expect( retryManager.executeWithRetry( operation, { retryableErrors: ['timeout', 'network'] }, 'test-operation' ) ).rejects.toThrow('parsing error'); expect(operation).toHaveBeenCalledTimes(1); }); it('should fail after max attempts', async () => { const operation = jest.fn().mockRejectedValue(new Error('timeout')); await expect( retryManager.executeWithRetry( operation, { maxAttempts: 2 }, 'test-operation' ) ).rejects.toThrow('Operation test-operation failed after 2 attempts'); expect(operation).toHaveBeenCalledTimes(2); }); }); describe('Backoff Strategies', () => { it('should use linear backoff', async () => { const operation = jest.fn().mockRejectedValue(new Error('timeout')); const startTime = Date.now(); try { await retryManager.executeWithRetry( operation, { maxAttempts: 3, delay: 100, backoffType: 'linear', jitter: false, }, 'test-operation' ); } catch (error) { // Expected to fail } const endTime = Date.now(); const totalTime = endTime - startTime; // Linear backoff: 100ms + 200ms = 300ms minimum expect(totalTime).toBeGreaterThan(250); }); it('should use exponential backoff', async () => { const operation = jest.fn().mockRejectedValue(new Error('timeout')); const startTime = Date.now(); try { await retryManager.executeWithRetry( operation, { maxAttempts: 3, delay: 100, backoffType: 'exponential', jitter: false, }, 'test-operation' ); } catch (error) { // Expected to fail } const endTime = Date.now(); const totalTime = endTime - startTime; // Exponential backoff: 100ms + 200ms = 300ms minimum expect(totalTime).toBeGreaterThan(250); }); it('should use fixed delay', async () => { const operation = jest.fn().mockRejectedValue(new Error('timeout')); const startTime = Date.now(); try { await retryManager.executeWithRetry( operation, { maxAttempts: 3, delay: 100, backoffType: 'fixed', jitter: false, }, 'test-operation' ); } catch (error) { // Expected to fail } const endTime = Date.now(); const totalTime = endTime - startTime; // Fixed backoff: 100ms + 100ms = 200ms minimum expect(totalTime).toBeGreaterThan(150); }); it('should respect maximum retry delay', async () => { const operation = jest.fn().mockRejectedValue(new Error('timeout')); // Use very high delay that should be capped try { await retryManager.executeWithRetry( operation, { maxAttempts: 2, delay: 5000, // Higher than maxRetryDelay (1000) backoffType: 'fixed', jitter: false, }, 'test-operation' ); } catch (error) { // Expected to fail } // Should not take more than maxRetryDelay expect(operation).toHaveBeenCalledTimes(2); }); }); }); describe('ErrorResilienceManager', () => { let resilienceManager: ErrorResilienceManager; beforeEach(() => { resilienceManager = new ErrorResilienceManager({ maxRetries: 2, baseRetryDelay: 50, maxRetryDelay: 500, maxConcurrentOperations: 3, }); }); describe('Resilient Execution', () => { it('should execute operation successfully', async () => { const operation = jest.fn().mockResolvedValue('success'); const result = await resilienceManager.executeResilient(operation, 'test-operation'); expect(result).toBe('success'); expect(operation).toHaveBeenCalledTimes(1); }); it('should apply all resilience features', async () => { const operation = jest.fn() .mockRejectedValueOnce(new Error('timeout')) .mockResolvedValue('success'); const result = await resilienceManager.executeResilient( operation, 'test-operation', { useCircuitBreaker: true, useRetry: true, useTimeout: true, } ); expect(result).toBe('success'); }); it('should enforce concurrent operation limits', async () => { const longRunningOperation = jest.fn().mockImplementation((): Promise<string> => new Promise(resolve => setTimeout(() => resolve('done'), 100)) ); // Start max concurrent operations const promises = []; for (let i = 0; i < 3; i++) { promises.push(resilienceManager.executeResilient(longRunningOperation, `operation-${i}`)); } // Fourth operation should be rejected await expect( resilienceManager.executeResilient(longRunningOperation, 'operation-4') ).rejects.toThrow('Maximum concurrent operations (3) exceeded'); // Wait for operations to complete await Promise.all(promises); }); it('should handle timeout correctly', async () => { // Create a resilience manager with very short timeouts const shortTimeoutManager = new ErrorResilienceManager({ operationTimeouts: { navigation: 50, // Very short timeout pageReady: 50, ruleExecution: 50, metadataExtraction: 50, screenshot: 50, }, }); const slowOperation = (): Promise<string> => new Promise(resolve => setTimeout(() => resolve('done'), 200)); await expect( shortTimeoutManager.executeResilient( slowOperation, 'navigation', { useTimeout: true } ) ).rejects.toThrow('Operation navigation timed out'); }); it('should work without optional features', async () => { const operation = jest.fn().mockResolvedValue('success'); const result = await resilienceManager.executeResilient( operation, 'test-operation', { useCircuitBreaker: false, useRetry: false, useTimeout: false, } ); expect(result).toBe('success'); }); }); describe('Error Categorization', () => { it('should categorize timeout errors', () => { const timeoutError = new Error('Operation timed out'); const categorized = resilienceManager.categorizeError(timeoutError, 'test-context'); expect(categorized.type).toBe('timeout'); expect(categorized.message).toBe('Operation timed out'); }); it('should categorize network errors', () => { const networkError = new Error('Network connection failed'); const categorized = resilienceManager.categorizeError(networkError, 'test-context'); expect(categorized.type).toBe('network'); expect(categorized.message).toBe('Network connection failed'); }); it('should categorize parsing errors', () => { const parseError = new Error('Invalid HTML syntax'); const categorized = resilienceManager.categorizeError(parseError, 'test-context'); expect(categorized.type).toBe('parsing'); expect(categorized.message).toBe('Invalid HTML syntax'); }); it('should default to runtime for unknown errors', () => { const unknownError = new Error('Something unexpected happened'); const categorized = resilienceManager.categorizeError(unknownError, 'test-context'); expect(categorized.type).toBe('runtime'); expect(categorized.message).toBe('Something unexpected happened'); }); it('should include context details', () => { const error = new Error('Test error'); const categorized = resilienceManager.categorizeError(error, 'test-context'); expect(categorized.details).toContain('test-context'); expect(categorized.details).toContain('Error'); }); }); describe('Status and Metrics', () => { it('should provide comprehensive status', async () => { const operation = jest.fn().mockResolvedValue('success'); await resilienceManager.executeResilient(operation, 'test-operation'); const status = resilienceManager.getStatus(); expect(status).toMatchObject({ config: expect.any(Object), activeOperations: 0, circuitBreakers: expect.any(Array), operationMetrics: expect.any(Object), }); }); it('should track active operations', async () => { const slowOperation = () => new Promise(resolve => setTimeout(resolve, 50)); const promise = resilienceManager.executeResilient(slowOperation, 'slow-operation'); // Check status while operation is running const statusDuring = resilienceManager.getStatus(); expect(statusDuring.activeOperations).toBe(1); await promise; // Check status after operation completes const statusAfter = resilienceManager.getStatus(); expect(statusAfter.activeOperations).toBe(0); }); it('should allow configuration updates', () => { const newConfig = { maxRetries: 5, baseRetryDelay: 200 }; resilienceManager.updateConfig(newConfig); const status = resilienceManager.getStatus(); expect(status.config.maxRetries).toBe(5); expect(status.config.baseRetryDelay).toBe(200); }); it('should allow reset of all metrics', async () => { const operation = jest.fn().mockResolvedValue('success'); await resilienceManager.executeResilient(operation, 'test-operation'); // Should have some metrics let status = resilienceManager.getStatus(); expect(Object.keys(status.operationMetrics)).toContain('test-operation'); resilienceManager.reset(); // Metrics should be cleared status = resilienceManager.getStatus(); expect(Object.keys(status.operationMetrics)).toHaveLength(0); }); }); describe('Error Creation Utilities', () => { it('should create retryable error with attempt information', () => { const originalError = new Error('Original failure'); const retryableError = resilienceManager.createRetryableError(originalError, 2, 3); expect(retryableError.message).toBe('Original failure (attempt 2/3)'); expect(retryableError.name).toBe('RetryableError'); expect(retryableError.stack).toBe(originalError.stack); }); }); });