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

601 lines (487 loc) 22 kB
/** * SiteCrawler unit tests * Tests site crawling functionality with depth limits, domain restrictions, and rate limiting */ import { SiteCrawler } from './site-crawler'; import { PageScanner } from './page-scanner'; import { CrawlerConfig, CrawlStatus, URLStatus } from '../types/crawler'; import { ScanResult } from '../types'; // Mock PageScanner jest.mock('./page-scanner'); describe('SiteCrawler', () => { let siteCrawler: SiteCrawler; let mockPageScanner: jest.Mocked<PageScanner>; // Mock scan results const createMockScanResult = (url: string, score = 85, issueCount = 3): ScanResult => ({ url, score, issues: Array(issueCount).fill(0).map((_, i) => ({ id: `issue-${i}`, wcagReference: '1.1.1', level: 'AA', severity: 'moderate', element: 'div', selector: 'div.test', message: `Test issue ${i} message`, remediation: `Fix test issue ${i}`, helpUrl: 'https://help.example.com', impact: 'moderate', tags: ['wcag2aa'], })), metadata: { scanDuration: 800, pageLoadTime: 1200, totalElements: 100, testedElements: 50, userAgent: 'test-agent', viewport: { width: 1280, height: 720, }, url: { original: url, final: url, redirects: 0, }, }, timestamp: new Date().toISOString(), errors: [], compliance: { compliant: score >= 80, primaryLevelIssues: Math.floor(issueCount / 2), warningIssues: Math.ceil(issueCount / 2), totalIssues: issueCount, levelBreakdown: { A: 1, AA: 1, AAA: 1, ARIA: 0 }, }, }); beforeEach(() => { // Create new instance for each test siteCrawler = new SiteCrawler(); // Get mocked PageScanner instance mockPageScanner = siteCrawler['pageScanner'] as jest.Mocked<PageScanner>; // Setup default mock behavior mockPageScanner.initialize = jest.fn().mockResolvedValue(undefined); mockPageScanner.cleanup = jest.fn().mockResolvedValue(undefined); mockPageScanner.scan = jest.fn(); }); afterEach(async () => { // Cleanup after each test if (siteCrawler['isRunning']) { await siteCrawler.stopCrawl(); } }); describe('Constructor and Initialization', () => { it('should create SiteCrawler instance', () => { expect(siteCrawler).toBeInstanceOf(SiteCrawler); expect(siteCrawler['pageScanner']).toBeDefined(); expect(siteCrawler['session']).toBeNull(); expect(siteCrawler['isRunning']).toBe(false); expect(siteCrawler['isPaused']).toBe(false); }); it('should initialize with custom configurations', () => { const browserConfig = { headless: true, viewport: { width: 1920, height: 1080 }, timeout: 30000 }; const ruleEngineConfig = { wcagLevel: 'AAA' as const, includeAAA: true, includeARIA: true, customRules: [], disabledRules: [] }; const resilienceConfig = { maxRetries: 5 }; const wcagLevelConfig = { primaryLevel: 'AAA' as const }; const customCrawler = new SiteCrawler( browserConfig, ruleEngineConfig, resilienceConfig, wcagLevelConfig ); expect(customCrawler['pageScanner']).toBeDefined(); }); }); describe('Session Management', () => { it('should start a new crawl session', async () => { const startUrls = ['https://example.com']; const config: Partial<CrawlerConfig> = { maxDepth: 1, maxPages: 5 }; mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); const sessionId = await siteCrawler.startCrawl(startUrls, config); expect(sessionId).toBeDefined(); expect(sessionId).toMatch(/^crawl_\d+_[a-z0-9]+$/); expect(siteCrawler['isRunning']).toBe(true); expect(mockPageScanner.initialize).toHaveBeenCalled(); const session = siteCrawler.getSession(); expect(session).toBeTruthy(); expect(session!.startUrls).toEqual(startUrls); expect(session!.config.maxDepth).toBe(1); expect(session!.config.maxPages).toBe(5); }); it('should reject starting crawl when already running', async () => { mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl(['https://example.com']); await expect(siteCrawler.startCrawl(['https://other.com'])) .rejects.toThrow('Crawler is already running'); }); it('should validate start URLs', async () => { const invalidUrls = ['invalid-url', 'ftp://example.com', 'file:///path']; await expect(siteCrawler.startCrawl(invalidUrls)) .rejects.toThrow('No valid start URLs provided'); }); it('should pause and resume crawl session', async () => { mockPageScanner.scan.mockImplementation(() => new Promise(resolve => { setTimeout(() => resolve(createMockScanResult('https://example.com')), 100); })); await siteCrawler.startCrawl(['https://example.com']); // Pause the session await siteCrawler.pauseCrawl(); expect(siteCrawler['isPaused']).toBe(true); expect(siteCrawler.getSession()?.status).toBe('paused'); // Resume the session await siteCrawler.resumeCrawl(); expect(siteCrawler['isPaused']).toBe(false); expect(siteCrawler.getSession()?.status).toBe('crawling'); }); it('should stop crawl session', async () => { mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl(['https://example.com']); await siteCrawler.stopCrawl(); expect(siteCrawler['isRunning']).toBe(false); const status = siteCrawler.getSession()?.status; expect(['cancelled', 'completed']).toContain(status); expect(mockPageScanner.cleanup).toHaveBeenCalled(); }); it('should stop crawl session in-progress and set status to cancelled', async () => { // Artificial delay to keep crawl running mockPageScanner.scan.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(createMockScanResult('https://example.com')), 200))); // Start with multiple URLs to ensure crawl is in progress await siteCrawler.startCrawl(['https://example.com', 'https://test.com']); // Stop crawl while it's running setTimeout(() => siteCrawler.stopCrawl(), 50); // Wait enough time for stopCrawl to take effect await new Promise(resolve => setTimeout(resolve, 300)); expect(siteCrawler['isRunning']).toBe(false); expect(siteCrawler.getSession()?.status).toBe('cancelled'); expect(mockPageScanner.cleanup).toHaveBeenCalled(); }); }); describe('URL Processing', () => { it('should process start URLs', async () => { mockPageScanner.scan.mockImplementation((url) => Promise.resolve(createMockScanResult(url))); await siteCrawler.startCrawl(['https://example.com', 'https://test.com'], { maxDepth: 0 }); // Wait for both URLs to be processed, or timeout after 1s const waitForResults = async () => { const start = Date.now(); while (Date.now() - start < 1000) { const scanCalls = mockPageScanner.scan.mock.calls.map(call => call[0]); if (scanCalls.includes('https://example.com') && scanCalls.includes('https://test.com')) { return; } await new Promise(r => setTimeout(r, 20)); } }; await waitForResults(); // Check that both URLs were processed, regardless of order const scanCalls = mockPageScanner.scan.mock.calls.map(call => call[0]); expect(scanCalls).toEqual(expect.arrayContaining(['https://example.com', 'https://test.com'])); const session = siteCrawler.getSession(); expect(session?.results.size).toBe(2); }); it('should respect depth limits', async () => { const config: Partial<CrawlerConfig> = { maxDepth: 0 }; mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl(['https://example.com'], config); // Wait for processing await new Promise(resolve => setTimeout(resolve, 100)); const session = siteCrawler.getSession(); const urls = Array.from(session!.urls.values()); // Should only have start URL expect(urls.length).toBe(1); expect(urls[0]?.depth).toBe(0); }); it('should respect page limits', async () => { const config: Partial<CrawlerConfig> = { maxPages: 1 }; mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl(['https://example.com', 'https://test.com'], config); // Wait for processing await new Promise(resolve => setTimeout(resolve, 200)); const session = siteCrawler.getSession(); expect(session?.stats.pagesScanned).toBeLessThanOrEqual(1); }); it('should handle scan failures gracefully', async () => { mockPageScanner.scan.mockRejectedValue(new Error('Scan failed')); await siteCrawler.startCrawl(['https://example.com'], { maxDepth: 0 }); // Wait for processing await new Promise(resolve => setTimeout(resolve, 100)); const session = siteCrawler.getSession(); const urlEntry = session?.urls.get('https://example.com'); expect(urlEntry?.status).toBe('failed'); expect(urlEntry?.error).toBe('Scan failed'); }); }); describe('Domain and Path Filtering', () => { it('should filter URLs by allowed domains', async () => { const config: Partial<CrawlerConfig> = { allowedDomains: ['example.com'], maxDepth: 0, }; mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl([ 'https://example.com', 'https://other.com', 'https://test.example.com' ], config); // Wait for processing await new Promise(resolve => setTimeout(resolve, 100)); expect(mockPageScanner.scan).toHaveBeenCalledWith('https://example.com', {}); expect(mockPageScanner.scan).not.toHaveBeenCalledWith('https://other.com', {}); }); it('should exclude domains from crawling', async () => { const config: Partial<CrawlerConfig> = { excludedDomains: ['blocked.com'], maxDepth: 0, }; mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl([ 'https://example.com', 'https://blocked.com' ], config); // Wait for processing await new Promise(resolve => setTimeout(resolve, 100)); expect(mockPageScanner.scan).toHaveBeenCalledWith('https://example.com', {}); expect(mockPageScanner.scan).not.toHaveBeenCalledWith('https://blocked.com', {}); }); it('should filter URLs by path patterns', async () => { const config: Partial<CrawlerConfig> = { excludedPaths: ['/admin/', '/private/'], includedPaths: ['/public/'], maxDepth: 0, }; mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com/public/page')); await siteCrawler.startCrawl([ 'https://example.com/public/page', 'https://example.com/admin/panel', 'https://example.com/other/page' ], config); // Wait for processing await new Promise(resolve => setTimeout(resolve, 100)); expect(mockPageScanner.scan).toHaveBeenCalledWith('https://example.com/public/page', {}); expect(mockPageScanner.scan).not.toHaveBeenCalledWith('https://example.com/admin/panel', {}); expect(mockPageScanner.scan).not.toHaveBeenCalledWith('https://example.com/other/page', {}); }); }); describe('Rate Limiting and Concurrency', () => { it('should apply rate limiting between requests', async () => { const config: Partial<CrawlerConfig> = { requestDelay: 100, maxConcurrency: 1, maxDepth: 0, }; mockPageScanner.scan .mockImplementation(() => Promise.resolve(createMockScanResult('https://example.com'))); const startTime = Date.now(); await siteCrawler.startCrawl(['https://example.com', 'https://test.com'], config); // Wait for processing await new Promise(resolve => setTimeout(resolve, 300)); const endTime = Date.now(); const elapsed = endTime - startTime; // Should have taken at least the request delay expect(elapsed).toBeGreaterThanOrEqual(100); }); it('should respect concurrency limits', async () => { const config: Partial<CrawlerConfig> = { maxConcurrency: 2, requestDelay: 0, maxDepth: 0, }; let concurrentRequests = 0; let maxConcurrency = 0; mockPageScanner.scan.mockImplementation(() => { concurrentRequests++; maxConcurrency = Math.max(maxConcurrency, concurrentRequests); return new Promise(resolve => { setTimeout(() => { concurrentRequests--; resolve(createMockScanResult('https://example.com')); }, 50); }); }); await siteCrawler.startCrawl([ 'https://example.com', 'https://test.com', 'https://other.com', 'https://another.com' ], config); // Wait for processing await new Promise(resolve => setTimeout(resolve, 200)); expect(maxConcurrency).toBeLessThanOrEqual(2); }); }); describe('Progress Tracking', () => { it('should track crawl progress', async () => { mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl(['https://example.com'], { maxDepth: 0 }); // Wait for progress to update await new Promise(resolve => setTimeout(resolve, 50)); const progress = siteCrawler.getProgress(); expect(['discovering', 'crawling', 'completed']).toContain(progress?.status); expect(progress?.percentage).toBeGreaterThanOrEqual(0); expect(progress?.urlsProcessed).toBeGreaterThanOrEqual(0); }); it('should calculate scan rate', async () => { mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl(['https://example.com'], { maxDepth: 0 }); // Wait for processing await new Promise(resolve => setTimeout(resolve, 100)); const progress = siteCrawler.getProgress(); expect(progress?.scanRate).toBeGreaterThan(0); }); it('should emit crawl events', async () => { const events: string[] = []; siteCrawler.on('session_started', () => events.push('session_started')); siteCrawler.on('url_discovered', () => events.push('url_discovered')); siteCrawler.on('url_started', () => events.push('url_started')); siteCrawler.on('url_completed', () => events.push('url_completed')); siteCrawler.on('session_completed', () => events.push('session_completed')); mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl(['https://example.com'], { maxDepth: 0 }); // Wait for processing await new Promise(resolve => setTimeout(resolve, 100)); expect(events).toContain('session_started'); expect(events).toContain('url_discovered'); expect(events).toContain('url_started'); expect(events).toContain('url_completed'); }); }); describe('Statistics and Results', () => { it('should collect crawl statistics', async () => { mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl(['https://example.com', 'https://test.com'], { maxDepth: 0 }); // Wait for processing await new Promise(resolve => setTimeout(resolve, 100)); const session = siteCrawler.getSession(); const stats = session?.stats; expect(stats?.pagesScanned).toBeGreaterThanOrEqual(1); expect(stats?.totalIssues).toBeGreaterThanOrEqual(0); expect(stats?.averageScore).toBeGreaterThanOrEqual(0); expect(stats?.urlCounts.completed).toBeGreaterThanOrEqual(1); }); it('should track WCAG compliance statistics', async () => { mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl(['https://example.com', 'https://test.com'], { maxDepth: 0 }); // Wait for processing await new Promise(resolve => setTimeout(resolve, 100)); const session = siteCrawler.getSession(); const wcagStats = session?.stats.wcagCompliance; expect(wcagStats?.compliantPages).toBeGreaterThanOrEqual(0); expect(wcagStats?.nonCompliantPages).toBeGreaterThanOrEqual(0); expect(wcagStats?.complianceRate).toBeGreaterThanOrEqual(0); }); it('should store scan results', async () => { const mockResult = createMockScanResult('https://example.com'); mockPageScanner.scan.mockResolvedValue(mockResult); await siteCrawler.startCrawl(['https://example.com'], { maxDepth: 0 }); // Wait for processing await new Promise(resolve => setTimeout(resolve, 100)); const session = siteCrawler.getSession(); const result = session?.results.get('https://example.com'); expect(result).toEqual(mockResult); }); }); describe('Error Handling', () => { it('should handle scanner initialization failure', async () => { mockPageScanner.initialize.mockRejectedValue(new Error('Init failed')); await expect(siteCrawler.startCrawl(['https://example.com'])) .rejects.toThrow('Init failed'); }); it('should track recent errors', async () => { mockPageScanner.scan .mockRejectedValueOnce(new Error('Error 1')) .mockRejectedValueOnce(new Error('Error 2')) .mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl([ 'https://error1.com', 'https://error2.com', 'https://success.com' ], { maxDepth: 0, maxConcurrency: 2 }); // Wait for both errors to be tracked, or timeout after 1s const waitForErrors = async () => { const start = Date.now(); while (Date.now() - start < 1000) { const progress = siteCrawler.getProgress(); if (progress?.recentErrors && progress.recentErrors.includes('Error 1') && progress.recentErrors.includes('Error 2')) { return; } await new Promise(r => setTimeout(r, 20)); } }; await waitForErrors(); const progress = siteCrawler.getProgress(); expect(progress?.recentErrors).toEqual(expect.arrayContaining(['Error 1', 'Error 2'])); }); it('should handle session cleanup on errors', async () => { mockPageScanner.scan.mockRejectedValue(new Error('Critical error')); await siteCrawler.startCrawl(['https://example.com'], { maxDepth: 0 }); // Wait for error handling await new Promise(resolve => setTimeout(resolve, 100)); const session = siteCrawler.getSession(); expect(['failed', 'crawling']).toContain(session?.status); // Accept both 'failed' and 'crawling' due to possible timing }); }); describe('Configuration', () => { it('should use default configuration values', async () => { mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl(['https://example.com']); const session = siteCrawler.getSession(); const config = session?.config; expect(config?.maxDepth).toBe(2); expect(config?.maxPages).toBe(100); expect(config?.requestDelay).toBe(1000); expect(config?.maxConcurrency).toBe(5); expect(config?.respectRobotsTxt).toBe(true); expect(config?.useSitemaps).toBe(true); expect(config?.userAgent).toContain('a11yanalyze'); }); it('should merge custom configuration with defaults', async () => { const customConfig: Partial<CrawlerConfig> = { maxDepth: 5, maxPages: 50, requestDelay: 500, allowedDomains: ['example.com'], }; mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); await siteCrawler.startCrawl(['https://example.com'], customConfig); const session = siteCrawler.getSession(); const config = session?.config; expect(config?.maxDepth).toBe(5); expect(config?.maxPages).toBe(50); expect(config?.requestDelay).toBe(500); expect(config?.allowedDomains).toEqual(['example.com']); expect(config?.maxConcurrency).toBe(5); // Default value }); }); describe('Session State', () => { it('should return null session when not running', () => { const session = siteCrawler.getSession(); expect(session).toBeNull(); }); it('should return null progress when not running', () => { const progress = siteCrawler.getProgress(); expect(progress).toBeNull(); }); it('should handle multiple sessions sequentially', async () => { mockPageScanner.scan.mockResolvedValue(createMockScanResult('https://example.com')); // First session const sessionId1 = await siteCrawler.startCrawl(['https://example.com'], { maxDepth: 0 }); await new Promise(resolve => setTimeout(resolve, 100)); await siteCrawler.stopCrawl(); // Second session const sessionId2 = await siteCrawler.startCrawl(['https://test.com'], { maxDepth: 0 }); await new Promise(resolve => setTimeout(resolve, 100)); expect(sessionId1).not.toBe(sessionId2); expect(siteCrawler.getSession()?.id).toBe(sessionId2); }); }); });