a11yanalyze
Version:
A command-line tool for developers and QA engineers to test web pages and websites for WCAG 2.2 AA accessibility compliance
902 lines (832 loc) • 30.5 kB
text/typescript
import { Page } from 'playwright';
import { ScanResult, ScanOptions, ScanMetadata, AccessibilityIssue, ScanError, BrowserConfig, RuleEngineConfig } from '../types';
import { BrowserManager } from '../utils/browser-manager';
import { RuleEngine } from './rule-engine';
import { ErrorResilienceManager, ResilienceConfig } from './error-resilience';
import { WCAGLevelHandler, WCAGLevelConfig } from './wcag-level-handler';
export interface KeyboardNavigationResult {
tabOrder: string[];
unreachableElements: string[];
focusTraps: string[];
missingFocusIndicators: string[];
issues: string[];
}
// Extend ScanResult type to include keyboardNavigation
export interface ScanResultWithKeyboard extends ScanResult {
keyboardNavigation?: KeyboardNavigationResult;
}
export interface ScreenReaderSimulationResult {
tree: any;
missingNames: string[];
ambiguousRoles: string[];
skippedElements: string[];
summary: string[];
}
export interface ScanResultWithA11y extends ScanResultWithKeyboard {
screenReaderSimulation?: ScreenReaderSimulationResult;
}
export interface CognitiveAccessibilityResult {
readingLevel: number;
readingLevelCategory: 'easy' | 'average' | 'difficult';
jargonTerms: string[];
abbreviationCount: number;
summary: string[];
}
export interface ScanResultWithCognitive extends ScanResultWithA11y {
cognitiveAccessibility?: CognitiveAccessibilityResult;
summary?: string[];
groupedWarnings?: Record<string, string[]>;
}
export async function simulateKeyboardNavigation(page: Page): Promise<KeyboardNavigationResult> {
// Find all focusable elements (inside page context)
const focusable = await page.evaluate(() => {
const selectors = [
'a[href]', 'button', 'input', 'select', 'textarea', '[tabindex]:not([tabindex="-1"])', '[contenteditable]'
];
return Array.from(document.querySelectorAll(selectors.join(','))).map(e => ({
html: (e as HTMLElement).outerHTML,
tag: e.tagName.toLowerCase(),
id: (e as HTMLElement).id,
className: (e as HTMLElement).className,
tabIndex: (e as HTMLElement).tabIndex,
isVisible: !!(e as HTMLElement).offsetParent,
isDisabled: (e as HTMLInputElement).disabled || false,
isContentEditable: (e as HTMLElement).isContentEditable,
isFocusable: true,
ariaHidden: (e as HTMLElement).getAttribute('aria-hidden') === 'true',
}));
});
// Find all interactable elements (clickable, input, button, etc.)
const interactable = await page.evaluate(() => {
const selectors = [
'a[href]', 'button', 'input', 'select', 'textarea', '[role="button"]', '[role="link"]', '[onclick]', '[tabindex]'
];
return Array.from(document.querySelectorAll(selectors.join(','))).map(e => ({
html: (e as HTMLElement).outerHTML,
tag: e.tagName.toLowerCase(),
id: (e as HTMLElement).id,
className: (e as HTMLElement).className,
tabIndex: (e as HTMLElement).tabIndex,
isVisible: !!(e as HTMLElement).offsetParent,
isDisabled: (e as HTMLInputElement).disabled || false,
isContentEditable: (e as HTMLElement).isContentEditable,
isFocusable: false,
ariaHidden: (e as HTMLElement).getAttribute('aria-hidden') === 'true',
}));
});
// Filter out aria-hidden elements from focusable/interactable arrays
const visibleFocusable = focusable.filter(e => !e.ariaHidden);
const visibleInteractable = interactable.filter(e => !e.ariaHidden);
const tabOrder: string[] = [];
const unreachableElements: string[] = [];
const focusTraps: string[] = [];
const missingFocusIndicators: string[] = [];
const focusOrderJumps: string[] = [];
const notInteractable: string[] = [];
const notFocusable: string[] = [];
const issues: string[] = [];
// Simulate tabbing through the page
let lastFocused = '';
let focusCycle = new Set<string>();
let lastDomIndex = -1;
for (let i = 0; i < visibleFocusable.length + 10; i++) { // +10 to detect cycles
await page.keyboard.press('Tab');
const active = await page.evaluate(() => {
const el = document.activeElement;
if (!el) return { desc: '', domIndex: -1 };
let desc = el.tagName.toLowerCase();
if ((el as HTMLElement).id) desc += `#${(el as HTMLElement).id}`;
if ((el as HTMLElement).className) desc += `.${(el as HTMLElement).className.split(' ').join('.')}`;
// Find DOM index
let domIndex = -1;
const all = Array.from(document.querySelectorAll('*'));
for (let j = 0; j < all.length; j++) {
if (all[j] === el) { domIndex = j; break; }
}
return { desc, domIndex };
});
if (!active.desc || tabOrder.includes(active.desc)) {
if (active.desc && focusCycle.has(active.desc)) {
focusTraps.push(active.desc);
issues.push(`Focus trap detected at ${active.desc}`);
break;
}
focusCycle.add(active.desc);
break;
}
// Detect focus order jumps
if (lastDomIndex !== -1 && active.domIndex !== -1 && active.domIndex < lastDomIndex) {
focusOrderJumps.push(active.desc);
issues.push(`Focus order jump detected at ${active.desc}`);
}
lastDomIndex = active.domIndex;
tabOrder.push(active.desc);
lastFocused = active.desc;
// Check for focus indicator (inside page context)
const hasIndicator = await page.evaluate(() => {
const el = document.activeElement as HTMLElement;
if (!el) return false;
const style = window.getComputedStyle(el);
return style.outlineStyle !== 'none' || style.boxShadow !== 'none';
});
if (!hasIndicator) {
missingFocusIndicators.push(active.desc);
issues.push(`Missing visible focus indicator on ${active.desc}`);
}
}
// Detect unreachable elements
for (const el of visibleFocusable) {
if (!tabOrder.some(sel => el.html.includes(sel))) {
unreachableElements.push(el.html);
issues.push(`Element not reachable by Tab: ${el.html}`);
}
}
// Detect focusable but not interactable
for (const el of visibleFocusable) {
if (!visibleInteractable.some(i => i.html === el.html)) {
notInteractable.push(el.html);
issues.push(`Focusable but not interactable: ${el.html}`);
}
}
// Detect interactable but not focusable
for (const el of visibleInteractable) {
if (!visibleFocusable.some(f => f.html === el.html)) {
notFocusable.push(el.html);
issues.push(`Interactable but not focusable: ${el.html}`);
}
}
return { tabOrder, unreachableElements, focusTraps, missingFocusIndicators, issues: [
...issues,
...focusOrderJumps.map(j => `Focus order jump at ${j}`),
...notInteractable.map(n => `Focusable but not interactable: ${n}`),
...notFocusable.map(n => `Interactable but not focusable: ${n}`)
] };
}
export async function simulateScreenReader(page: Page): Promise<ScreenReaderSimulationResult> {
// Get the accessibility tree
const tree = await page.accessibility.snapshot({ interestingOnly: false });
const missingNames: string[] = [];
const ambiguousRoles: string[] = [];
const skippedElements: string[] = [];
const summary: string[] = [];
function walk(node: any, path: string[] = []) {
if (!node) return;
const label = node.name || '';
const role = node.role || '';
const nodePath = [...path, role || node.name || 'unknown'].join(' > ');
if (!label && ['button', 'link', 'textbox', 'checkbox', 'radio', 'img'].includes(role)) {
missingNames.push(nodePath);
summary.push(`Element with role '${role}' missing accessible name at ${nodePath}`);
}
if (role === 'generic' || !role) {
ambiguousRoles.push(nodePath);
summary.push(`Element with ambiguous or missing role at ${nodePath}`);
}
if (node.focusable && node.name === undefined) {
skippedElements.push(nodePath);
summary.push(`Focusable element skipped by accessibility tree at ${nodePath}`);
}
if (node.children) {
for (const child of node.children) {
walk(child, [...path, role || node.name || 'unknown']);
}
}
}
walk(tree);
return { tree, missingNames, ambiguousRoles, skippedElements, summary };
}
export async function analyzeCognitiveAccessibility(page: Page): Promise<CognitiveAccessibilityResult> {
// Extract all visible text
const text = await page.evaluate(() => {
function getText(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) return node.textContent || '';
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).offsetParent !== null) {
let t = '';
for (const child of Array.from(node.childNodes)) t += getText(child);
return t;
}
return '';
}
return getText(document.body);
});
// Flesch-Kincaid reading level
function countSyllables(word: string): number {
word = word.toLowerCase();
if (word.length <= 3) return 1;
word = word.replace(/(?:e$)/, '');
const matches = word.match(/[aeiouy]{1,2}/g);
return matches ? matches.length : 1;
}
function countWords(str: string): number {
return (str.match(/\b\w+\b/g) || []).length;
}
function countSentences(str: string): number {
return (str.match(/[.!?]+/g) || []).length;
}
function countSyllablesInText(str: string): number {
return (str.match(/\b\w+\b/g) || []).reduce((sum, word) => sum + countSyllables(word), 0);
}
const words = countWords(text);
const sentences = countSentences(text);
const syllables = countSyllablesInText(text);
const readingLevel = sentences > 0 ? 0.39 * (words / sentences) + 11.8 * (syllables / words) - 15.59 : 0;
let readingLevelCategory: 'easy' | 'average' | 'difficult' = 'easy';
if (readingLevel > 10) readingLevelCategory = 'difficult';
else if (readingLevel > 7) readingLevelCategory = 'average';
// Jargon/abbreviation detection (simple)
const jargonList = ['utilize', 'leverage', 'synergy', 'paradigm', 'bandwidth', 'stakeholder', 'streamline', 'incentivize', 'touchpoint', 'vertical', 'granular', 'pivot', 'ecosystem', 'disrupt', 'scalable', 'proactive', 'ideate', 'circle back', 'low-hanging fruit'];
const jargonTerms = jargonList.filter(j => text.toLowerCase().includes(j));
const abbreviationCount = (text.match(/\b[A-Z]{2,}\b/g) || []).length;
const summary = [
`Flesch-Kincaid reading level: ${readingLevel.toFixed(1)} (${readingLevelCategory})`,
`Jargon terms detected: ${jargonTerms.length > 0 ? jargonTerms.join(', ') : 'None'}`,
`Abbreviations detected: ${abbreviationCount}`
];
return { readingLevel, readingLevelCategory, jargonTerms, abbreviationCount, summary };
}
/**
* Core single page accessibility scanner
* Implements WCAG 2.2 AA testing with hybrid rule engine and JavaScript rendering support
*/
export class PageScanner {
private browserManager: BrowserManager;
private ruleEngine: RuleEngine;
private resilienceManager: ErrorResilienceManager;
private wcagLevelHandler: WCAGLevelHandler;
private isInitialized = false;
constructor(
browserConfig: BrowserConfig = {
headless: true,
viewport: { width: 1280, height: 720 },
timeout: 30000,
},
ruleEngineConfig: RuleEngineConfig = {
wcagLevel: 'AA',
includeAAA: false,
includeARIA: true,
customRules: [],
disabledRules: [],
},
resilienceConfig: Partial<ResilienceConfig> = {},
wcagLevelConfig: Partial<WCAGLevelConfig> = {}
) {
this.browserManager = new BrowserManager(browserConfig);
this.ruleEngine = new RuleEngine(ruleEngineConfig);
this.resilienceManager = new ErrorResilienceManager(resilienceConfig);
this.wcagLevelHandler = new WCAGLevelHandler({
primaryLevel: ruleEngineConfig.wcagLevel,
includeAAA: ruleEngineConfig.includeAAA,
includeARIA: ruleEngineConfig.includeARIA,
...wcagLevelConfig,
});
}
/**
* Initialize the page scanner with browser and rule engine setup
*/
async initialize(): Promise<void> {
if (this.isInitialized) {
return;
}
try {
await this.resilienceManager.executeResilient(
() => this.browserManager.initialize(),
'initialization',
{
useCircuitBreaker: false, // Don't use circuit breaker for initialization
useRetry: true,
useTimeout: true,
retryStrategy: {
maxAttempts: 2,
backoffType: 'fixed',
retryableErrors: ['timeout', 'network'],
},
}
);
this.isInitialized = true;
} catch (error) {
const scanError = this.resilienceManager.categorizeError(error as Error, 'PageScanner initialization');
throw new Error(`Failed to initialize PageScanner: ${scanError.message}`);
}
}
/**
* Scan a single page for accessibility issues with JavaScript rendering support
* @param url - URL to scan
* @param options - Scanning options
* @returns Promise<ScanResult>
*/
async scan(url: string, options: Partial<ScanOptions> = {}): Promise<ScanResultWithCognitive> {
const startTime = Date.now();
const scanOptions = {
wcagLevel: options.wcagLevel ?? 'AA',
includeWarnings: options.includeWarnings ?? true,
timeout: options.timeout ?? 30000,
pageReadyTimeout: options.pageReadyTimeout ?? 10000, // new option
outputPath: options.outputPath,
includeAAA: options.includeAAA ?? false,
includeARIA: options.includeARIA ?? true,
screenshot: options.screenshot ?? false,
customRules: options.customRules ?? [],
disabledRules: options.disabledRules ?? [],
};
// Ensure scanner is initialized
if (!this.isInitialized) {
await this.initialize();
}
// Validate URL
if (!this.isValidUrl(url)) {
throw new Error(`Invalid URL provided: ${url}`);
}
let page: Page | null = null;
const errors: ScanError[] = [];
const issues: AccessibilityIssue[] = [];
try {
// Create a new page with resilience
page = await this.resilienceManager.executeResilient(
() => this.browserManager.createPage(),
'pageCreation',
{
useRetry: true,
retryStrategy: {
maxAttempts: 2,
backoffType: 'linear',
retryableErrors: ['timeout', 'runtime'],
},
}
);
// Navigate to the URL with enhanced error handling and retries
const navigationResult = await this.resilienceManager.executeResilient(
() => this.browserManager.navigateToUrl(page!, url, {
timeout: scanOptions.timeout,
waitUntil: 'domcontentloaded',
retries: 1, // Reduced since we have resilience layer
retryDelay: 500,
}),
'navigation',
{
useRetry: true,
retryStrategy: {
maxAttempts: 3,
backoffType: 'exponential',
retryableErrors: ['timeout', 'network'],
},
}
);
// Handle navigation errors
if (!navigationResult.success) {
const errorType = navigationResult.error?.type ?? 'network';
const mappedType = errorType === 'navigation' || errorType === 'javascript' || errorType === 'security'
? 'runtime'
: errorType as 'timeout' | 'network' | 'parsing' | 'runtime';
errors.push({
type: mappedType,
message: navigationResult.error?.message ?? 'Navigation failed',
details: navigationResult.error?.details ? JSON.stringify(navigationResult.error.details) : undefined,
});
}
// Update rule engine configuration based on scan options
await this.updateRuleEngineConfig(scanOptions);
// Wait for page to be fully loaded (including JavaScript) with resilience
await this.resilienceManager.executeResilient(
() => this.waitForPageReady(page!, scanOptions.pageReadyTimeout),
'pageReady',
{
useRetry: true,
retryStrategy: {
maxAttempts: 2,
backoffType: 'linear',
retryableErrors: ['timeout'],
},
}
);
// Extract page metadata with resilience
const metadata = await this.resilienceManager.executeResilient(
() => this.extractPageMetadata(page!, navigationResult, startTime),
'metadataExtraction',
{
useRetry: true,
retryStrategy: {
maxAttempts: 2,
backoffType: 'fixed',
retryableErrors: ['runtime', 'timeout'],
},
}
);
// Execute accessibility rules with enhanced resilience
try {
const ruleResults = await this.resilienceManager.executeResilient(
() => this.ruleEngine.executeRules(page!),
'ruleExecution',
{
useRetry: true,
retryStrategy: {
maxAttempts: 2,
backoffType: 'linear',
retryableErrors: ['runtime', 'timeout'],
},
}
);
// Transform rule results to accessibility issues
for (const ruleResult of ruleResults) {
const accessibilityIssues = this.transformRuleResultToIssues(ruleResult);
issues.push(...accessibilityIssues);
}
} catch (ruleError) {
const scanError = this.resilienceManager.categorizeError(ruleError as Error, 'Rule execution');
errors.push(scanError);
}
// Calculate accessibility score
let score = this.calculateAccessibilityScore(issues, metadata.testedElements);
// Reduce score if there are warnings
const warningCount = issues.filter(i => i.severity === 'warning').length;
if (warningCount > 0) {
score = Math.max(0, score - warningCount * 2);
}
// Get WCAG compliance summary
const complianceSummary = this.wcagLevelHandler.getComplianceSummary(issues);
// Take screenshot if requested with resilience
if (scanOptions.screenshot && page) {
try {
await this.resilienceManager.executeResilient(
() => page!.screenshot({
path: `screenshot-${Date.now()}.png`,
fullPage: true,
}),
'screenshot',
{
useRetry: true,
retryStrategy: {
maxAttempts: 2,
backoffType: 'fixed',
retryableErrors: ['timeout', 'runtime'],
},
}
);
} catch (screenshotError) {
const scanError = this.resilienceManager.categorizeError(screenshotError as Error, 'Screenshot capture');
errors.push(scanError);
}
}
const keyboardNavigationResult = await simulateKeyboardNavigation(page);
const screenReaderSimulationResult = await simulateScreenReader(page);
const cognitiveResult = await analyzeCognitiveAccessibility(page);
// Group similar warnings and add remediation tips
const groupedWarnings: Record<string, string[]> = {};
for (const issue of issues.filter(i => i.severity === 'warning')) {
const key = issue.message;
if (!groupedWarnings[key]) groupedWarnings[key] = [];
groupedWarnings[key].push(issue.element);
}
// Add summary to result
const summary = [
`Total issues: ${issues.length}`,
`Warnings: ${warningCount}`,
`Passed checks: ${metadata.testedElements - issues.length}`
];
return {
url,
timestamp: new Date().toISOString(),
score,
issues,
errors: errors.length > 0 ? errors : undefined,
metadata,
compliance: complianceSummary,
keyboardNavigation: keyboardNavigationResult,
screenReaderSimulation: screenReaderSimulationResult,
cognitiveAccessibility: cognitiveResult,
summary,
groupedWarnings,
} as ScanResultWithCognitive;
} catch (error) {
// Handle unexpected errors with proper categorization
const scanError = this.resilienceManager.categorizeError(error as Error, 'Page scan operation');
errors.push(scanError);
// Return partial result even on failure
const metadata: ScanMetadata = {
scanDuration: Date.now() - startTime,
pageLoadTime: 0,
totalElements: 0,
testedElements: 0,
userAgent: 'unknown',
viewport: { width: 1280, height: 720 },
url: {
original: url,
final: url,
redirects: 0,
},
};
return {
url,
timestamp: new Date().toISOString(),
score: 0,
issues,
errors,
metadata,
} as ScanResultWithCognitive;
} finally {
// Always cleanup the page
if (page) {
try {
await this.browserManager.closePage(page);
} catch {
// Ignore cleanup errors
}
}
}
}
/**
* Update rule engine configuration based on scan options
* @private
*/
private async updateRuleEngineConfig(options: {
wcagLevel: any;
includeAAA: boolean;
includeARIA: boolean;
customRules: string[];
disabledRules: string[];
}): Promise<void> {
// Update rule engine with current scan options
this.ruleEngine.updateConfiguration({
wcagLevel: options.wcagLevel,
includeAAA: options.includeAAA,
includeARIA: options.includeARIA,
customRules: options.customRules.map((ruleId: string) => ({
ruleId,
wcagCriterion: '1.1.1', // Placeholder - would be properly configured
level: 'AA' as const,
enabled: true,
tags: [],
impact: 'moderate' as const,
evaluate: '',
})),
disabledRules: options.disabledRules,
});
}
/**
* Wait for page to be fully ready including JavaScript execution
* @private
*/
private async waitForPageReady(page: Page, timeout: number): Promise<void> {
try {
// Wait for network to be idle
await page.waitForLoadState('networkidle', { timeout: Math.min(timeout, 10000) });
// Wait for any pending JavaScript to complete
await page.waitForFunction(
() => {
// Check if page is ready
const doc = (globalThis as any).document;
const win = (globalThis as any).window;
return doc?.readyState === 'complete' &&
win?.performance?.navigation?.type !== undefined;
},
{ timeout: Math.min(timeout, 5000) }
);
// Give additional time for dynamic content
await page.waitForTimeout(1000);
} catch (error) {
// Continue even if wait conditions fail - page might still be scannable
console.warn('Page ready wait timeout, continuing with scan:', error);
}
}
/**
* Extract comprehensive page metadata
* @private
*/
private async extractPageMetadata(
page: Page,
navigationResult: any,
startTime: number
): Promise<ScanMetadata> {
try {
const pageInfo = await page.evaluate(() => {
const doc = (globalThis as any).document;
const win = (globalThis as any).window;
const nav = (globalThis as any).navigator;
const elements = doc?.querySelectorAll('*') || [];
const interactiveElements = doc?.querySelectorAll(
'a, button, input, select, textarea, [tabindex], [role="button"], [role="link"]'
) || [];
return {
totalElements: elements.length,
interactiveElements: interactiveElements.length,
title: doc?.title || '',
url: win?.location?.href || '',
userAgent: nav?.userAgent || '',
viewport: {
width: win?.innerWidth || 1280,
height: win?.innerHeight || 720,
},
};
});
return {
scanDuration: Date.now() - startTime,
pageLoadTime: navigationResult?.loadTime ?? 0,
totalElements: pageInfo.totalElements,
testedElements: pageInfo.interactiveElements,
userAgent: pageInfo.userAgent,
viewport: pageInfo.viewport,
url: {
original: navigationResult?.url ?? page.url(),
final: pageInfo.url,
redirects: navigationResult?.redirects ?? 0,
},
};
} catch (error) {
// Return default metadata if extraction fails
return {
scanDuration: Date.now() - startTime,
pageLoadTime: 0,
totalElements: 0,
testedElements: 0,
userAgent: 'unknown',
viewport: { width: 1280, height: 720 },
url: {
original: page.url(),
final: page.url(),
redirects: 0,
},
};
}
}
/**
* Transform rule results to accessibility issues
* @private
*/
private transformRuleResultToIssues(ruleResult: any): AccessibilityIssue[] {
const issues: AccessibilityIssue[] = [];
for (const node of ruleResult.nodes || []) {
const issue: AccessibilityIssue = {
id: ruleResult.ruleId,
wcagReference: ruleResult.wcagReference,
level: ruleResult.level,
severity: this.wcagLevelHandler.mapToSeverity(
ruleResult.level,
ruleResult.impact,
ruleResult.wcagReference
),
element: this.extractElementName(node.html),
selector: Array.isArray(node.target) ? node.target.join(', ') : node.target,
message: ruleResult.description,
remediation: ruleResult.help,
helpUrl: ruleResult.helpUrl,
impact: ruleResult.impact,
tags: ruleResult.tags || [],
};
issues.push(issue);
}
// Filter issues based on WCAG level configuration
return this.wcagLevelHandler.filterIssues(issues);
}
/**
* Map axe impact levels to severity levels
* @private
*/
private mapImpactToSeverity(impact: string): AccessibilityIssue['severity'] {
switch (impact?.toLowerCase()) {
case 'critical':
return 'critical';
case 'serious':
return 'serious';
case 'moderate':
return 'moderate';
case 'minor':
return 'minor';
default:
return 'warning';
}
}
/**
* Extract element name from HTML string
* @private
*/
private extractElementName(html: string): string {
const match = html.match(/<(\w+)/);
return match?.[1]?.toLowerCase() ?? 'unknown';
}
/**
* Calculate accessibility score based on issues and page complexity
* @private
*/
private calculateAccessibilityScore(issues: AccessibilityIssue[], totalElements: number): number {
if (issues.length === 0) {
return 100;
}
// Weight different severity levels
const weights = {
critical: 10,
serious: 5,
moderate: 2,
minor: 1,
warning: 0.5,
};
const totalWeight = issues.reduce((sum, issue) => {
return sum + (weights[issue.severity] || 1);
}, 0);
// Calculate base score considering page complexity
const complexityFactor = Math.max(1, Math.log10(totalElements || 1));
const normalizedWeight = totalWeight / complexityFactor;
// Score from 0-100, where lower weight = higher score
const score = Math.max(0, 100 - (normalizedWeight * 5));
return Math.round(score * 100) / 100; // Round to 2 decimal places
}
/**
* Validate URL format
* @private
*/
private isValidUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
return ['http:', 'https:'].includes(parsedUrl.protocol);
} catch {
return false;
}
}
/**
* Get current scanner status
*/
getStatus(): {
initialized: boolean;
browserStatus: any;
ruleEngineConfig: any;
resilienceStatus: any;
wcagLevelConfig: any;
} {
return {
initialized: this.isInitialized,
browserStatus: this.browserManager.getStatus(),
ruleEngineConfig: this.ruleEngine.getConfiguration(),
resilienceStatus: this.resilienceManager.getStatus(),
wcagLevelConfig: this.wcagLevelHandler.getConfig(),
};
}
/**
* Get detailed resilience metrics and status
*/
getResilienceStatus() {
return this.resilienceManager.getStatus();
}
/**
* Reset resilience state (circuit breakers, metrics, etc.)
*/
resetResilience(): void {
this.resilienceManager.reset();
}
/**
* Update scanner configuration
*/
async updateConfiguration(
browserConfig?: Partial<BrowserConfig>,
ruleEngineConfig?: Partial<RuleEngineConfig>,
resilienceConfig?: Partial<ResilienceConfig>,
wcagLevelConfig?: Partial<WCAGLevelConfig>
): Promise<void> {
if (ruleEngineConfig) {
this.ruleEngine.updateConfiguration(ruleEngineConfig);
// Update WCAG level handler with new rule engine settings
if (ruleEngineConfig.wcagLevel !== undefined ||
ruleEngineConfig.includeAAA !== undefined ||
ruleEngineConfig.includeARIA !== undefined) {
this.wcagLevelHandler.updateConfig({
primaryLevel: ruleEngineConfig.wcagLevel,
includeAAA: ruleEngineConfig.includeAAA,
includeARIA: ruleEngineConfig.includeARIA,
});
}
}
if (wcagLevelConfig) {
this.wcagLevelHandler.updateConfig(wcagLevelConfig);
}
if (resilienceConfig) {
this.resilienceManager.updateConfig(resilienceConfig);
}
// Browser config updates require re-initialization
if (browserConfig && this.isInitialized) {
await this.cleanup();
this.browserManager = new BrowserManager({
...this.browserManager.getStatus(),
...browserConfig,
} as BrowserConfig);
await this.initialize();
}
}
/**
* Cleanup browser and other resources
*/
async cleanup(): Promise<void> {
try {
if (this.isInitialized) {
await this.browserManager.cleanup();
this.isInitialized = false;
}
// Note: We don't reset resilience metrics on cleanup to preserve historical data
// Use resetResilience() explicitly if needed
} catch (error) {
console.warn('Error during PageScanner cleanup:', error);
}
}
/**
* Set storage state (cookies, localStorage, etc.) for the browser context
*/
public async setStorageState(storageState: any): Promise<void> {
await this.browserManager.setStorageState(storageState);
}
}