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

593 lines (473 loc) 20.4 kB
/** * ConfigManager unit tests * Tests comprehensive configuration management with multiple sources and validation */ import { ConfigManager, A11yAnalyzeConfig, ConfigLoadResult, ConfigSource } from './config-manager'; import { writeFileSync, unlinkSync, existsSync, mkdirSync, rmSync } from 'fs'; import { join } from 'path'; // Mock filesystem operations for testing jest.mock('fs', () => ({ readFileSync: jest.fn(), writeFileSync: jest.fn(), existsSync: jest.fn(), unlinkSync: jest.fn(), mkdirSync: jest.fn(), rmSync: jest.fn(), })); jest.mock('path', () => ({ ...jest.requireActual('path'), resolve: jest.fn(), join: jest.fn(), })); jest.mock('os', () => ({ homedir: jest.fn(() => '/home/user'), })); const mockFs = require('fs'); const mockPath = require('path'); describe('ConfigManager', () => { let configManager: ConfigManager; let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { configManager = new ConfigManager(); originalEnv = { ...process.env }; // Reset mocks jest.clearAllMocks(); // Setup default mock behaviors mockFs.existsSync.mockReturnValue(false); mockPath.resolve.mockImplementation((...args: string[]) => args.join('/')); mockPath.join.mockImplementation((...args: string[]) => args.join('/')); }); afterEach(() => { // Restore environment process.env = originalEnv; }); describe('Default Configuration', () => { it('should provide sensible default configuration', async () => { const result = await configManager.loadConfig(); expect(result.config).toBeDefined(); expect(result.config.scanning.wcagLevel).toBe('AA'); expect(result.config.scanning.includeAAA).toBe(false); expect(result.config.scanning.includeARIA).toBe(true); expect(result.config.scanning.timeout).toBe(30000); expect(result.config.browser.headless).toBe(true); expect(result.config.browser.viewport.width).toBe(1280); expect(result.config.browser.viewport.height).toBe(720); expect(result.config.crawling.maxDepth).toBe(2); expect(result.config.crawling.maxPages).toBe(50); expect(result.config.output.format).toBe('console'); expect(result.config.scoring.profile).toBe('balanced'); }); it('should include only default source when no other config found', async () => { const result = await configManager.loadConfig(); expect(result.sources).toHaveLength(1); expect(result.sources[0]?.type).toBe('defaults'); expect(result.sources[0]?.loaded).toBe(true); expect(result.sources[0]?.priority).toBe(0); }); it('should provide static access to default config', () => { const defaultConfig = ConfigManager.getDefaultConfig(); expect(defaultConfig.scanning.wcagLevel).toBe('AA'); expect(defaultConfig.browser.headless).toBe(true); expect(defaultConfig.scoring.profile).toBe('balanced'); }); }); describe('Configuration File Loading', () => { it('should load JSON configuration file', async () => { const configData = { scanning: { wcagLevel: 'AAA', timeout: 45000, }, output: { format: 'json', verbose: true, }, }; mockFs.existsSync.mockImplementation((path: string) => path.includes('.a11yanalyzerc.json') ); mockFs.readFileSync.mockReturnValue(JSON.stringify(configData)); const result = await configManager.loadConfig(); expect(result.config.scanning.wcagLevel).toBe('AAA'); expect(result.config.scanning.timeout).toBe(45000); expect(result.config.output.format).toBe('json'); expect(result.config.output.verbose).toBe(true); expect(result.sources.some(s => s.type === 'file')).toBe(true); }); it('should load JavaScript configuration file', async () => { const configPath = '/test/.a11yanalyzerc.js'; mockFs.existsSync.mockImplementation((path: string) => path === configPath ); // Mock require for JS config const mockConfig = { scanning: { wcagLevel: 'A' }, browser: { headless: false }, }; const originalRequire = require; (global as any).require = jest.fn().mockImplementation((path: string) => { if (path === configPath) return mockConfig; return originalRequire(path); }); // Mock require.cache and require.resolve require.cache = {}; (require.resolve as any) = jest.fn().mockReturnValue(configPath); try { const result = await configManager.loadConfig(['/test']); expect(result.config.scanning.wcagLevel).toBe('A'); expect(result.config.browser.headless).toBe(false); } finally { (global as any).require = originalRequire; } }); it('should load configuration from package.json', async () => { const packageData = { name: 'test-project', a11yanalyze: { scanning: { wcagLevel: 'AAA', includeAAA: true, }, scoring: { profile: 'strict', }, }, }; mockFs.existsSync.mockImplementation((path: string) => path.includes('package.json') ); mockFs.readFileSync.mockReturnValue(JSON.stringify(packageData)); const result = await configManager.loadConfig(); expect(result.config.scanning.wcagLevel).toBe('AAA'); expect(result.config.scanning.includeAAA).toBe(true); expect(result.config.scoring.profile).toBe('strict'); }); it('should handle file loading errors gracefully', async () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockImplementation(() => { throw new Error('File read error'); }); const result = await configManager.loadConfig(); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0]).toContain('File read error'); expect(result.config).toBeDefined(); // Should fall back to defaults }); it('should handle invalid JSON gracefully', async () => { mockFs.existsSync.mockImplementation((path: string) => path.includes('.a11yanalyzerc.json') ); mockFs.readFileSync.mockReturnValue('{ invalid json }'); const result = await configManager.loadConfig(); expect(result.errors.length).toBeGreaterThan(0); expect(result.config).toBeDefined(); // Should fall back to defaults }); }); describe('Environment Variable Loading', () => { it('should load configuration from environment variables', async () => { process.env.A11Y_WCAG_LEVEL = 'AAA'; process.env.A11Y_INCLUDE_AAA = 'true'; process.env.A11Y_TIMEOUT = '60000'; process.env.A11Y_HEADLESS = 'false'; process.env.A11Y_VIEWPORT_WIDTH = '1920'; process.env.A11Y_VIEWPORT_HEIGHT = '1080'; process.env.A11Y_OUTPUT_FORMAT = 'json'; process.env.A11Y_VERBOSE = 'true'; const result = await configManager.loadConfig(); expect(result.config.scanning.wcagLevel).toBe('AAA'); expect(result.config.scanning.includeAAA).toBe(true); expect(result.config.scanning.timeout).toBe(60000); expect(result.config.browser.headless).toBe(false); expect(result.config.browser.viewport.width).toBe(1920); expect(result.config.browser.viewport.height).toBe(1080); expect(result.config.output.format).toBe('json'); expect(result.config.output.verbose).toBe(true); expect(result.sources.some(s => s.type === 'environment')).toBe(true); }); it('should parse different environment variable types correctly', async () => { process.env.A11Y_INCLUDE_AAA = 'true'; process.env.A11Y_TIMEOUT = '45000'; process.env.A11Y_VIEWPORT_WIDTH = '1600'; const result = await configManager.loadConfig(); expect(typeof result.config.scanning.includeAAA).toBe('boolean'); expect(typeof result.config.scanning.timeout).toBe('number'); expect(typeof result.config.browser.viewport.width).toBe('number'); expect(result.config.scanning.includeAAA).toBe(true); expect(result.config.scanning.timeout).toBe(45000); expect(result.config.browser.viewport.width).toBe(1600); }); it('should handle invalid environment variables with warnings', async () => { process.env.A11Y_TIMEOUT = 'invalid-number'; const result = await configManager.loadConfig(); expect(result.warnings.length).toBeGreaterThan(0); expect(result.warnings.some(w => w.includes('A11Y_TIMEOUT'))).toBe(true); }); }); describe('CLI Options Loading', () => { it('should load configuration from CLI options', async () => { const cliOptions = { wcagLevel: 'AAA', includeAaa: true, timeout: '45000', viewport: '1920x1080', format: 'json', verbose: true, profile: 'strict', }; const result = await configManager.loadConfig([], cliOptions); expect(result.config.scanning.wcagLevel).toBe('AAA'); expect(result.config.scanning.includeAAA).toBe(true); expect(result.config.scanning.timeout).toBe(45000); expect(result.config.browser.viewport.width).toBe(1920); expect(result.config.browser.viewport.height).toBe(1080); expect(result.config.output.format).toBe('json'); expect(result.config.output.verbose).toBe(true); expect(result.config.scoring.profile).toBe('strict'); expect(result.sources.some(s => s.type === 'cli')).toBe(true); }); it('should parse viewport string correctly', async () => { const cliOptions = { viewport: '1600x900', }; const result = await configManager.loadConfig([], cliOptions); expect(result.config.browser.viewport.width).toBe(1600); expect(result.config.browser.viewport.height).toBe(900); }); it('should handle invalid viewport format gracefully', async () => { const cliOptions = { viewport: 'invalid-format', }; const result = await configManager.loadConfig([], cliOptions); // Should not crash and should use defaults for viewport expect(result.config.browser.viewport.width).toBe(1280); expect(result.config.browser.viewport.height).toBe(720); }); }); describe('Configuration Priority and Merging', () => { it('should apply configuration sources in correct priority order', async () => { // Setup file config const fileConfig = { scanning: { wcagLevel: 'A', timeout: 20000 }, output: { format: 'json' }, }; mockFs.existsSync.mockImplementation((path: string) => path.includes('.a11yanalyzerc.json') ); mockFs.readFileSync.mockReturnValue(JSON.stringify(fileConfig)); // Setup environment config process.env.A11Y_WCAG_LEVEL = 'AAA'; process.env.A11Y_VERBOSE = 'true'; // Setup CLI config const cliOptions = { wcagLevel: 'AA', quiet: true, }; const result = await configManager.loadConfig([], cliOptions); // CLI should override everything expect(result.config.scanning.wcagLevel).toBe('AA'); expect(result.config.output.quiet).toBe(true); // Environment should override file but not CLI expect(result.config.output.verbose).toBe(true); // File should override defaults but not environment/CLI expect(result.config.output.format).toBe('json'); expect(result.config.scanning.timeout).toBe(20000); // Verify source priorities const sources = result.sources; const defaultSource = sources.find(s => s.type === 'defaults'); const fileSource = sources.find(s => s.type === 'file'); const envSource = sources.find(s => s.type === 'environment'); const cliSource = sources.find(s => s.type === 'cli'); expect(defaultSource?.priority).toBe(0); expect(fileSource?.priority).toBe(1); expect(envSource?.priority).toBe(2); expect(cliSource?.priority).toBe(3); }); it('should deeply merge nested configuration objects', async () => { const fileConfig = { browser: { headless: false, viewport: { width: 1600 }, }, scanning: { wcagLevel: 'A', }, }; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(fileConfig)); const cliOptions = { timeout: '45000', }; const result = await configManager.loadConfig([], cliOptions); // Should merge browser config expect(result.config.browser.headless).toBe(false); // From file expect(result.config.browser.viewport.width).toBe(1600); // From file expect(result.config.browser.viewport.height).toBe(720); // From default expect(result.config.browser.userAgent).toContain('A11yAnalyze'); // From default // Should merge scanning config expect(result.config.scanning.wcagLevel).toBe('A'); // From file expect(result.config.scanning.timeout).toBe(45000); // From CLI expect(result.config.scanning.includeARIA).toBe(true); // From default }); }); describe('Configuration Validation', () => { it('should validate WCAG level', async () => { const cliOptions = { wcagLevel: 'INVALID_LEVEL', }; const result = await configManager.loadConfig([], cliOptions); expect(result.errors.some(e => e.includes('Invalid WCAG level'))).toBe(true); }); it('should validate scoring profile', async () => { const cliOptions = { profile: 'invalid_profile', }; const result = await configManager.loadConfig([], cliOptions); expect(result.errors.some(e => e.includes('Invalid scoring profile'))).toBe(true); }); it('should validate timeout values', async () => { const fileConfig = { scanning: { timeout: -1000 }, }; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(fileConfig)); const result = await configManager.loadConfig(); expect(result.errors.some(e => e.includes('timeout must be greater than 0'))).toBe(true); }); it('should validate viewport dimensions', async () => { const fileConfig = { browser: { viewport: { width: -100, height: 0 }, }, }; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(fileConfig)); const result = await configManager.loadConfig(); expect(result.errors.some(e => e.includes('viewport dimensions must be greater than 0'))).toBe(true); }); it('should validate crawling configuration', async () => { const fileConfig = { crawling: { maxDepth: -5, maxConcurrency: 0, maxPages: -10, }, }; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(fileConfig)); const result = await configManager.loadConfig(); expect(result.errors.some(e => e.includes('depth cannot be negative'))).toBe(true); expect(result.errors.some(e => e.includes('concurrency must be greater than 0'))).toBe(true); expect(result.warnings.some(w => w.includes('Max pages is 0 or negative'))).toBe(true); }); }); describe('Configuration Generation and Saving', () => { it('should generate sample JSON configuration', () => { const jsonConfig = configManager.generateSampleConfig('json'); expect(() => JSON.parse(jsonConfig)).not.toThrow(); const parsed = JSON.parse(jsonConfig); expect(parsed.scanning).toBeDefined(); expect(parsed.browser).toBeDefined(); expect(parsed.output).toBeDefined(); }); it('should generate sample JavaScript configuration', () => { const jsConfig = configManager.generateSampleConfig('js'); expect(jsConfig).toContain('module.exports = {'); expect(jsConfig).toContain('scanning'); expect(jsConfig).toContain('browser'); }); it('should generate sample YAML configuration', () => { const yamlConfig = configManager.generateSampleConfig('yaml'); expect(yamlConfig).toContain('scanning:'); expect(yamlConfig).toContain('browser:'); expect(yamlConfig).toContain('wcagLevel:'); }); it('should save configuration to file', async () => { const customConfig = { scanning: { wcagLevel: 'AAA' as const }, output: { format: 'json' as const }, }; await configManager.saveConfig(customConfig, 'test-config.json'); expect(mockFs.writeFileSync).toHaveBeenCalledWith( 'test-config.json', expect.stringContaining('"wcagLevel": "AAA"'), 'utf8' ); }); it('should handle save errors gracefully', async () => { mockFs.writeFileSync.mockImplementation(() => { throw new Error('Write failed'); }); await expect(configManager.saveConfig({}, 'test.json')).rejects.toThrow('Failed to save configuration'); }); }); describe('Configuration Access and State', () => { it('should return null when no config is loaded', () => { const config = configManager.getConfig(); expect(config).toBeNull(); }); it('should return loaded config after loading', async () => { await configManager.loadConfig(); const config = configManager.getConfig(); expect(config).toBeDefined(); expect(config?.scanning.wcagLevel).toBe('AA'); }); it('should return configuration sources information', async () => { const result = await configManager.loadConfig(); const sources = configManager.getConfigSources(); expect(sources).toEqual(result.sources); expect(sources.length).toBeGreaterThan(0); expect(sources[0]?.type).toBe('defaults'); }); it('should preserve configuration between calls', async () => { await configManager.loadConfig(); const config1 = configManager.getConfig(); const config2 = configManager.getConfig(); expect(config1).toBe(config2); // Same reference }); }); describe('Error Handling and Edge Cases', () => { it('should handle empty configuration files', async () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue('{}'); const result = await configManager.loadConfig(); expect(result.config).toBeDefined(); expect(result.errors).toHaveLength(0); // Should use defaults for missing values expect(result.config.scanning.wcagLevel).toBe('AA'); }); it('should handle malformed package.json', async () => { mockFs.existsSync.mockImplementation((path: string) => path.includes('package.json') ); mockFs.readFileSync.mockReturnValue('{ "name": "test" }'); // No a11yanalyze key const result = await configManager.loadConfig(); expect(result.config).toBeDefined(); expect(result.config.scanning.wcagLevel).toBe('AA'); // Should use defaults }); it('should handle empty search paths gracefully', async () => { const result = await configManager.loadConfig([]); expect(result.config).toBeDefined(); expect(result.sources.some(s => s.type === 'defaults')).toBe(true); }); it('should handle undefined CLI options gracefully', async () => { const cliOptions = { wcagLevel: undefined, timeout: null, verbose: false, }; const result = await configManager.loadConfig([], cliOptions); expect(result.config).toBeDefined(); expect(result.config.scanning.wcagLevel).toBe('AA'); // Should use default expect(result.config.output.verbose).toBe(false); // Should use CLI value }); it('should recover from total configuration failure', async () => { // Simulate complete failure mockFs.existsSync.mockImplementation(() => { throw new Error('Filesystem error'); }); const result = await configManager.loadConfig(); expect(result.errors.length).toBeGreaterThan(0); expect(result.config).toBeDefined(); expect(result.config.scanning.wcagLevel).toBe('AA'); // Should fall back to defaults }); }); });