a11yanalyze
Version:
A command-line tool for developers and QA engineers to test web pages and websites for WCAG 2.2 AA accessibility compliance
835 lines (752 loc) • 23.7 kB
text/typescript
/**
* Configuration Management System
* Provides progressive configuration with sensible defaults, file-based config,
* environment variables, and CLI argument overrides
*/
import { readFileSync, existsSync, writeFileSync } from 'fs';
import { join, resolve } from 'path';
import { homedir } from 'os';
import { WCAGLevel } from '../types/wcag';
import { ScoringProfile } from '../scoring/accessibility-scorer';
import yaml from 'js-yaml';
import Ajv from 'ajv';
// Define JSON Schema for A11yAnalyzeConfig (partial, for demonstration; expand as needed)
// NOTE: Use 'any' for now to allow extensibility; replace with full JSONSchemaType<A11yAnalyzeConfig> for strict typing
const configSchema: any = {
type: 'object',
properties: {
scanning: {
type: 'object',
properties: {
wcagLevel: { type: 'string', enum: ['A', 'AA', 'AAA', 'ARIA'] },
includeAAA: { type: 'boolean' },
includeARIA: { type: 'boolean' },
includeWarnings: { type: 'boolean' },
timeout: { type: 'integer', minimum: 1 },
retries: { type: 'integer', minimum: 0 },
retryDelay: { type: 'integer', minimum: 0 },
waitForNetworkIdle: { type: 'boolean' },
captureScreenshots: { type: 'boolean' },
screenshotOnFailure: { type: 'boolean' },
customRules: { type: 'array', items: { type: 'string' } },
disabledRules: { type: 'array', items: { type: 'string' } },
},
required: [
'wcagLevel', 'includeAAA', 'includeARIA', 'includeWarnings', 'timeout', 'retries', 'retryDelay',
'waitForNetworkIdle', 'captureScreenshots', 'screenshotOnFailure', 'customRules', 'disabledRules'
],
additionalProperties: true
},
browser: { type: 'object', additionalProperties: true },
crawling: { type: 'object', additionalProperties: true },
output: { type: 'object', additionalProperties: true },
scoring: { type: 'object', additionalProperties: true },
issues: { type: 'object', additionalProperties: true },
performance: { type: 'object', additionalProperties: true },
advanced: { type: 'object', additionalProperties: true },
vpat: { type: 'object', additionalProperties: true },
storybook: { type: 'object', additionalProperties: true },
reporting: { type: 'object', additionalProperties: true },
},
required: [
'scanning', 'browser', 'crawling', 'output', 'scoring', 'issues', 'performance', 'advanced', 'vpat', 'storybook', 'reporting'
],
additionalProperties: false
};
const ajv = new Ajv({ allErrors: true });
const validateConfigSchema = ajv.compile(configSchema);
export interface VpatConfig {
enabled: boolean;
mode: 'component' | 'page' | 'storybook';
outputFormat: 'json' | 'markdown' | 'vpat2';
section508: boolean;
remarks: boolean;
jiraIntegration: boolean;
}
export interface StorybookConfig {
enabled: boolean;
url: string;
iframeSelector: string;
componentIsolation: boolean;
autoDiscover: boolean;
}
export interface ReportingConfig {
format: 'json' | 'markdown' | 'vpat2' | 'section508';
templatePath?: string;
jiraIntegration: boolean;
includeScreenshots: boolean;
includeSummary: boolean;
}
/**
* Core configuration interface
*/
export interface A11yAnalyzeConfig {
/** Scanning configuration */
scanning: {
wcagLevel: WCAGLevel;
includeAAA: boolean;
includeARIA: boolean;
includeWarnings: boolean;
timeout: number;
retries: number;
retryDelay: number;
waitForNetworkIdle: boolean;
captureScreenshots: boolean;
screenshotOnFailure: boolean;
customRules: string[];
disabledRules: string[];
};
/** Browser configuration */
browser: {
headless: boolean;
viewport: {
width: number;
height: number;
};
userAgent: string;
enableJavaScript: boolean;
allowInsecure: boolean;
locale: string;
timezone: string;
};
/** Crawling configuration */
crawling: {
maxDepth: number;
maxPages: number;
maxConcurrency: number;
requestDelay: number;
respectRobotsTxt: boolean;
followRedirects: boolean;
allowedDomains: string[];
excludedDomains: string[];
excludedPaths: string[];
includedPaths: string[];
useSitemaps: boolean;
discoverExternalLinks: boolean;
maxExternalDepth: number;
};
/** Output and reporting configuration */
output: {
format: 'json' | 'console' | 'both';
verbose: boolean;
quiet: boolean;
debug: boolean;
colors: boolean;
timestamps: boolean;
progressBars: boolean;
outputPath?: string;
templatePath?: string;
exportErrors: boolean;
errorLogPath?: string;
};
/** Scoring configuration */
scoring: {
profile: ScoringProfile;
minScore: number;
maxScore: number;
enableBonuses: boolean;
enablePenalties: boolean;
minSeverity: 'critical' | 'serious' | 'moderate' | 'minor' | 'warning';
customWeights?: {
wcagLevels?: Record<WCAGLevel, number>;
severities?: Record<string, number>;
};
};
/** Issue processing configuration */
issues: {
groupSimilar: boolean;
includeRemediation: boolean;
includeCodeExamples: boolean;
includeTestingGuidance: boolean;
contextAware: boolean;
maxCodeExamples: number;
priorityMode: 'severity' | 'impact' | 'effort' | 'quickWins';
};
/** Performance and resilience configuration */
performance: {
enableCircuitBreaker: boolean;
circuitBreakerThreshold: number;
maxRetries: number;
baseRetryDelay: number;
timeoutStrategy: 'fixed' | 'adaptive';
memoryLimit: number;
resourceCleanup: boolean;
};
/** Advanced configuration */
advanced: {
experimentalFeatures: boolean;
cacheResults: boolean;
cacheTTL: number;
telemetry: boolean;
updateCheck: boolean;
configVersion: string;
};
vpat: VpatConfig;
storybook: StorybookConfig;
reporting: ReportingConfig;
}
/**
* Configuration file formats supported
*/
export type ConfigFormat = 'json' | 'js' | 'yaml';
/**
* Configuration source information
*/
export interface ConfigSource {
type: 'defaults' | 'file' | 'environment' | 'cli';
path?: string;
priority: number;
loaded: boolean;
errors?: string[];
}
/**
* Configuration loading result
*/
export interface ConfigLoadResult {
config: A11yAnalyzeConfig;
sources: ConfigSource[];
warnings: string[];
errors: string[];
}
/**
* Configuration Manager
* Handles loading, merging, and validation of configuration from multiple sources
*/
export class ConfigManager {
private static readonly CONFIG_FILENAMES = [
'.a11yanalyzerc.json',
'.a11yanalyzerc.js',
'a11yanalyze.config.json',
'a11yanalyze.config.js',
'package.json', // Look for a11yanalyze key
];
private static readonly DEFAULT_CONFIG: A11yAnalyzeConfig = {
scanning: {
wcagLevel: 'AA',
includeAAA: false,
includeARIA: true,
includeWarnings: true,
timeout: 30000,
retries: 2,
retryDelay: 1000,
waitForNetworkIdle: true,
captureScreenshots: false,
screenshotOnFailure: true,
customRules: [],
disabledRules: [],
},
browser: {
headless: true,
viewport: {
width: 1280,
height: 720,
},
userAgent: 'A11yAnalyze/1.0.0 (Accessibility Testing Tool)',
enableJavaScript: true,
allowInsecure: false,
locale: 'en-US',
timezone: 'UTC',
},
crawling: {
maxDepth: 2,
maxPages: 50,
maxConcurrency: 3,
requestDelay: 1000,
respectRobotsTxt: false,
followRedirects: true,
allowedDomains: [],
excludedDomains: [],
excludedPaths: [],
includedPaths: [],
useSitemaps: false,
discoverExternalLinks: false,
maxExternalDepth: 1,
},
output: {
format: 'console',
verbose: false,
quiet: false,
debug: false,
colors: true,
timestamps: false,
progressBars: true,
exportErrors: false,
},
scoring: {
profile: 'balanced',
minScore: 0,
maxScore: 100,
enableBonuses: true,
enablePenalties: true,
minSeverity: 'moderate',
},
issues: {
groupSimilar: true,
includeRemediation: true,
includeCodeExamples: true,
includeTestingGuidance: true,
contextAware: true,
maxCodeExamples: 3,
priorityMode: 'impact',
},
performance: {
enableCircuitBreaker: true,
circuitBreakerThreshold: 5,
maxRetries: 3,
baseRetryDelay: 1000,
timeoutStrategy: 'adaptive',
memoryLimit: 512, // MB
resourceCleanup: true,
},
advanced: {
experimentalFeatures: false,
cacheResults: false,
cacheTTL: 3600, // 1 hour
telemetry: false,
updateCheck: true,
configVersion: '1.0.0',
},
vpat: {
enabled: false,
mode: 'component',
outputFormat: 'json',
section508: true,
remarks: true,
jiraIntegration: false,
},
storybook: {
enabled: false,
url: '',
iframeSelector: '#storybook-preview-iframe',
componentIsolation: true,
autoDiscover: true,
},
reporting: {
format: 'json',
templatePath: undefined,
jiraIntegration: false,
includeScreenshots: true,
includeSummary: true,
},
};
private loadedConfig?: A11yAnalyzeConfig;
private configSources: ConfigSource[] = [];
/**
* Load configuration from all sources with proper priority
*/
async loadConfig(
searchPaths: string[] = [],
cliOptions: Record<string, any> = {}
): Promise<ConfigLoadResult> {
const warnings: string[] = [];
const errors: string[] = [];
this.configSources = [];
try {
// 1. Start with default configuration
let config = this.deepClone(ConfigManager.DEFAULT_CONFIG);
this.addConfigSource('defaults', 0, true);
// 2. Load from configuration files
const fileConfig = await this.loadFromFiles(searchPaths);
if (fileConfig.config) {
config = this.mergeConfigs(config, fileConfig.config);
this.addConfigSource('file', 1, true, fileConfig.path);
}
warnings.push(...fileConfig.warnings);
errors.push(...fileConfig.errors);
// 3. Load from environment variables
const envConfig = this.loadFromEnvironment();
if (envConfig.config) {
config = this.mergeConfigs(config, envConfig.config);
this.addConfigSource('environment', 2, true);
}
warnings.push(...envConfig.warnings);
// 4. Apply CLI options (highest priority)
const cliConfig = this.convertCliToConfig(cliOptions);
if (cliConfig.config) {
config = this.mergeConfigs(config, cliConfig.config);
this.addConfigSource('cli', 3, true);
}
// 5. Validate final configuration
const validation = this.validateConfig(config);
warnings.push(...validation.warnings);
errors.push(...validation.errors);
// Store loaded configuration
this.loadedConfig = config;
return {
config,
sources: this.configSources,
warnings,
errors,
};
} catch (error) {
errors.push(`Configuration loading failed: ${error instanceof Error ? error.message : String(error)}`);
// Return default config on failure
return {
config: this.deepClone(ConfigManager.DEFAULT_CONFIG),
sources: [{ type: 'defaults', priority: 0, loaded: true }],
warnings,
errors,
};
}
}
/**
* Get the currently loaded configuration
*/
getConfig(): A11yAnalyzeConfig | null {
return this.loadedConfig || null;
}
/**
* Get configuration sources information
*/
getConfigSources(): ConfigSource[] {
return [...this.configSources];
}
/**
* Generate a sample configuration file
*/
generateSampleConfig(format: ConfigFormat = 'json'): string {
const config = ConfigManager.DEFAULT_CONFIG;
switch (format) {
case 'json':
return JSON.stringify(config, null, 2);
case 'js':
return `module.exports = ${JSON.stringify(config, null, 2)};`;
case 'yaml':
// Simple YAML generation - in production, use a proper YAML library
return this.convertToYaml(config);
default:
throw new Error(`Unsupported config format: ${format}`);
}
}
/**
* Save configuration to file
*/
async saveConfig(
config: any,
path: string,
format: ConfigFormat = 'json'
): Promise<void> {
try {
const fullConfig = this.mergeConfigs(ConfigManager.DEFAULT_CONFIG, config);
const content = this.generateSampleConfig(format);
writeFileSync(path, content, 'utf8');
} catch (error) {
throw new Error(`Failed to save configuration: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get default configuration
*/
static getDefaultConfig(): A11yAnalyzeConfig {
return this.prototype.deepClone(ConfigManager.DEFAULT_CONFIG);
}
/**
* Load configuration from files
* @private
*/
private async loadFromFiles(searchPaths: string[]): Promise<{
config?: Partial<A11yAnalyzeConfig>;
path?: string;
warnings: string[];
errors: string[];
}> {
const warnings: string[] = [];
const errors: string[] = [];
// Build search paths
const paths = [
...searchPaths,
process.cwd(),
homedir(),
];
for (const basePath of paths) {
for (const filename of ConfigManager.CONFIG_FILENAMES) {
const configPath = resolve(basePath, filename);
if (existsSync(configPath)) {
try {
const config = await this.loadConfigFile(configPath);
return { config, path: configPath, warnings, errors };
} catch (error) {
errors.push(`Failed to load config from ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
}
return { warnings, errors };
}
/**
* Load a specific configuration file
* @private
*/
private async loadConfigFile(path: string): Promise<Partial<A11yAnalyzeConfig>> {
const content = readFileSync(path, 'utf8');
if (path.endsWith('.json')) {
return JSON.parse(content);
} else if (path.endsWith('.js')) {
// For .js files, use dynamic import or require
delete require.cache[require.resolve(path)];
return require(path);
} else if (path.endsWith('.yaml') || path.endsWith('.yml')) {
return yaml.load(content) as Partial<A11yAnalyzeConfig>;
} else if (path.includes('package.json')) {
const pkg = JSON.parse(content);
return pkg.a11yanalyze || {};
}
throw new Error(`Unsupported config file format: ${path}`);
}
/**
* Load configuration from environment variables
* @private
*/
private loadFromEnvironment(): {
config?: Partial<A11yAnalyzeConfig>;
warnings: string[];
} {
const warnings: string[] = [];
const config: any = {};
// Map environment variables to config structure
const envMappings: Record<string, string> = {
'A11Y_WCAG_LEVEL': 'scanning.wcagLevel',
'A11Y_INCLUDE_AAA': 'scanning.includeAAA',
'A11Y_INCLUDE_ARIA': 'scanning.includeARIA',
'A11Y_TIMEOUT': 'scanning.timeout',
'A11Y_HEADLESS': 'browser.headless',
'A11Y_VIEWPORT_WIDTH': 'browser.viewport.width',
'A11Y_VIEWPORT_HEIGHT': 'browser.viewport.height',
'A11Y_MAX_DEPTH': 'crawling.maxDepth',
'A11Y_MAX_PAGES': 'crawling.maxPages',
'A11Y_CONCURRENCY': 'crawling.maxConcurrency',
'A11Y_OUTPUT_FORMAT': 'output.format',
'A11Y_VERBOSE': 'output.verbose',
'A11Y_QUIET': 'output.quiet',
'A11Y_DEBUG': 'output.debug',
'A11Y_SCORING_PROFILE': 'scoring.profile',
'A11Y_MIN_SEVERITY': 'scoring.minSeverity',
};
for (const [envVar, configPath] of Object.entries(envMappings)) {
const value = process.env[envVar];
if (value !== undefined && value !== null) {
try {
this.setNestedValue(config, configPath, this.parseEnvValue(value));
} catch (error) {
warnings.push(`Invalid environment variable ${envVar}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
return { config: Object.keys(config).length > 0 ? config : undefined, warnings };
}
/**
* Convert CLI options to configuration structure
* @private
*/
private convertCliToConfig(cliOptions: Record<string, any>): {
config?: Partial<A11yAnalyzeConfig>;
} {
const config: any = {};
// Map CLI options to config structure
const mappings: Record<string, string> = {
'wcagLevel': 'scanning.wcagLevel',
'includeAaa': 'scanning.includeAAA',
'includeAria': 'scanning.includeARIA',
'timeout': 'scanning.timeout',
'viewport': 'browser.viewport',
'depth': 'crawling.maxDepth',
'maxPages': 'crawling.maxPages',
'concurrency': 'crawling.maxConcurrency',
'format': 'output.format',
'verbose': 'output.verbose',
'quiet': 'output.quiet',
'debug': 'output.debug',
'output': 'output.outputPath',
'exportErrors': 'output.exportErrors',
'profile': 'scoring.profile',
'minSeverity': 'scoring.minSeverity',
'screenshot': 'scanning.captureScreenshots',
};
for (const [cliKey, configPath] of Object.entries(mappings)) {
const value = cliOptions[cliKey];
if (value !== undefined) {
if (cliKey === 'viewport' && typeof value === 'string') {
// Parse viewport string like "1280x720"
const match = value.match(/^(\d+)x(\d+)$/);
if (match && match[1] && match[2]) {
this.setNestedValue(config, 'browser.viewport.width', parseInt(match[1], 10));
this.setNestedValue(config, 'browser.viewport.height', parseInt(match[2], 10));
}
} else {
this.setNestedValue(config, configPath, value);
}
}
}
return { config: Object.keys(config).length > 0 ? config : undefined };
}
/**
* Validate configuration
* @private
*/
private validateConfig(config: A11yAnalyzeConfig): {
warnings: string[];
errors: string[];
} {
const warnings: string[] = [];
const errors: string[] = [];
// JSON Schema validation
const valid = validateConfigSchema(config);
if (!valid) {
for (const err of validateConfigSchema.errors || []) {
errors.push(`Config schema error: ${err.instancePath} ${err.message}`);
}
}
// Validate WCAG level
const validWcagLevels: WCAGLevel[] = ['A', 'AA', 'AAA', 'ARIA'];
if (!validWcagLevels.includes(config.scanning.wcagLevel)) {
errors.push(`Invalid WCAG level: ${config.scanning.wcagLevel}. Must be one of: ${validWcagLevels.join(', ')}`);
}
// Validate scoring profile
const validProfiles: ScoringProfile[] = ['strict', 'balanced', 'lenient', 'enterprise', 'custom'];
if (!validProfiles.includes(config.scoring.profile)) {
errors.push(`Invalid scoring profile: ${config.scoring.profile}. Must be one of: ${validProfiles.join(', ')}`);
}
// Validate timeout values
if (config.scanning.timeout <= 0) {
errors.push('Scanning timeout must be greater than 0');
}
// Validate viewport
if (config.browser.viewport.width <= 0 || config.browser.viewport.height <= 0) {
errors.push('Browser viewport dimensions must be greater than 0');
}
// Validate crawling limits
if (config.crawling.maxDepth < 0) {
errors.push('Max crawl depth cannot be negative');
}
if (config.crawling.maxPages <= 0) {
warnings.push('Max pages is 0 or negative, this may prevent scanning');
}
// Validate concurrency
if (config.crawling.maxConcurrency <= 0) {
errors.push('Max concurrency must be greater than 0');
}
return { warnings, errors };
}
/**
* Merge two configuration objects deeply
* @private
*/
private mergeConfigs(base: A11yAnalyzeConfig, override: Partial<A11yAnalyzeConfig>): A11yAnalyzeConfig {
const result = this.deepClone(base);
return this.deepMerge(result, override);
}
/**
* Deep clone an object
* @private
*/
private deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as any;
if (obj instanceof Array) return obj.map(item => this.deepClone(item)) as any;
const cloned = {} as any;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = this.deepClone(obj[key]);
}
}
return cloned;
}
/**
* Deep merge objects
* @private
*/
private deepMerge(target: any, source: any): any {
for (const key in source) {
if (source.hasOwnProperty(key)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!target[key] || typeof target[key] !== 'object') {
target[key] = {};
}
this.deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
/**
* Set nested object value using dot notation
* @private
*/
private setNestedValue(obj: any, path: string, value: any): void {
const keys = path.split('.');
if (keys.length === 0) return;
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!key) continue;
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key];
}
const finalKey = keys[keys.length - 1];
if (finalKey) {
current[finalKey] = value;
}
}
/**
* Parse environment variable value to appropriate type
* @private
*/
private parseEnvValue(value: string): any {
// Boolean values
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
// Number values
if (/^\d+$/.test(value)) return parseInt(value, 10);
if (/^\d+\.\d+$/.test(value)) return parseFloat(value);
// Array values (comma-separated)
if (value.includes(',')) {
return value.split(',').map(v => v.trim());
}
// String value
return value;
}
/**
* Convert configuration to YAML format (simplified)
* @private
*/
private convertToYaml(obj: any, indent = 0): string {
const spaces = ' '.repeat(indent);
let yaml = '';
for (const [key, value] of Object.entries(obj)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
yaml += `${spaces}${key}:\n${this.convertToYaml(value, indent + 1)}`;
} else if (Array.isArray(value)) {
yaml += `${spaces}${key}:\n`;
for (const item of value) {
yaml += `${spaces} - ${JSON.stringify(item)}\n`;
}
} else {
yaml += `${spaces}${key}: ${JSON.stringify(value)}\n`;
}
}
return yaml;
}
/**
* Add configuration source to tracking
* @private
*/
private addConfigSource(
type: ConfigSource['type'],
priority: number,
loaded: boolean,
path?: string,
errors?: string[]
): void {
this.configSources.push({
type,
priority,
loaded,
path,
errors,
});
}
}