@casoon/auditmysite
Version:
Professional website analysis suite with robust accessibility testing, Core Web Vitals performance monitoring, SEO analysis, and content optimization insights. Features isolated browser contexts, retry mechanisms, and comprehensive API endpoints for profe
531 lines • 22.3 kB
JavaScript
;
/**
* AccessibilityChecker v2 - Clean Architecture
*
* This is a complete rewrite of the AccessibilityChecker with proper
* separation of concerns, dependency injection, and typed interfaces.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AccessibilityTestError = exports.AccessibilityChecker = void 0;
const pa11y_1 = __importDefault(require("pa11y"));
const queue_1 = require("../queue");
const analyzer_factory_1 = require("../analyzers/analyzer-factory");
const analysis_orchestrator_1 = require("../analysis/analysis-orchestrator");
const structured_logger_1 = require("../logging/structured-logger");
const redirect_detector_1 = require("./redirect-detector");
const result_factory_1 = require("./result-factory");
const constants_1 = require("./constants");
/**
* AccessibilityChecker v2 - Clean, focused implementation
*
* Responsibilities:
* - Core accessibility testing via pa11y
* - Basic page analysis (images, buttons, headings)
* - Orchestration of comprehensive analysis (via AnalysisOrchestrator)
* - Browser pool management coordination
*/
class AccessibilityChecker {
constructor(config) {
// Validate required dependencies
if (!config.poolManager) {
throw new Error('BrowserPoolManager is required');
}
// Set up configuration with defaults
this.config = {
poolManager: config.poolManager,
logger: config.logger || (0, structured_logger_1.createLogger)('accessibility-checker'),
enableComprehensiveAnalysis: config.enableComprehensiveAnalysis || false,
analyzerTypes: config.analyzerTypes || [],
qualityAnalysisOptions: config.qualityAnalysisOptions || {}
};
this.logger = this.config.logger;
// Initialize analyzer factory if comprehensive analysis is enabled
if (this.config.enableComprehensiveAnalysis) {
try {
const factoryConfig = {
logger: this.logger.child ? this.logger.child('factory') : this.logger,
qualityAnalysisOptions: this.config.qualityAnalysisOptions,
enabledAnalyzers: this.config.analyzerTypes.length > 0 ? this.config.analyzerTypes : undefined
};
this.analyzerFactory = new analyzer_factory_1.AnalyzerFactory(factoryConfig);
// Initialize analysis orchestrator
const orchestratorConfig = {
analyzerFactory: this.analyzerFactory,
logger: this.logger.child ? this.logger.child('orchestrator') : this.logger,
defaultTimeout: 30000,
failFast: false
};
this.analysisOrchestrator = new analysis_orchestrator_1.AnalysisOrchestrator(orchestratorConfig);
}
catch (error) {
this.logger.warn('Failed to initialize comprehensive analysis - continuing with basic accessibility testing only', error);
// Cannot modify readonly config - comprehensive analysis will remain disabled for this session
}
}
}
/**
* Initialize the accessibility checker
*/
async initialize() {
this.logger.info('AccessibilityChecker initialized with browser pooling');
}
/**
* Cleanup resources
*/
async cleanup() {
if (this.analyzerFactory) {
await this.analyzerFactory.cleanup();
}
this.logger.info('AccessibilityChecker cleaned up');
}
/**
* Test a single page for accessibility
*/
async testPage(url, options = {}) {
const startTime = Date.now();
const logger = this.logger.child ? this.logger.child('test-page') : this.logger;
logger.info(`Testing page: ${url}`);
const { browser, context, release } = await this.config.poolManager.acquire();
try {
const page = await context.newPage();
try {
// Configure page settings
await this.configurePage(page, options);
// Set up redirect detection
const redirectDetector = (0, redirect_detector_1.createRedirectDetector)({
skipRedirects: options.skipRedirects,
logger: logger.child ? logger.child('redirect') : logger
});
const { getResult, cleanup } = redirectDetector.attachToPage(page);
try {
// Navigate to page
const response = await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: options.timeout || constants_1.TIMEOUTS.DEFAULT_NAVIGATION
});
// Check for HTTP errors
if (!response || (0, constants_1.isHttpError)(response.status())) {
throw new Error(`HTTP ${response?.status() || 'unknown'} error`);
}
// Check for redirects
const redirectInfo = getResult(response, url);
if (options.skipRedirects !== false && redirectInfo.isRedirect) {
const duration = Date.now() - startTime;
logger.info(`Skipping redirected URL`, redirectInfo);
return result_factory_1.ResultFactory.createRedirectResult(redirectInfo, duration);
}
// Run basic accessibility analysis
const accessibilityResult = await this.runBasicAccessibilityAnalysis(page, url, options);
// Run comprehensive analysis if enabled
let comprehensiveAnalysis;
if (this.shouldRunComprehensiveAnalysis(options)) {
comprehensiveAnalysis = await this.runComprehensiveAnalysis(page, url, options);
}
const duration = Date.now() - startTime;
const result = {
url,
title: accessibilityResult.title,
accessibilityResult,
comprehensiveAnalysis,
duration,
timestamp: new Date()
};
logger.info(`Page testing completed`, {
url,
duration,
passed: accessibilityResult.passed
});
return result;
}
finally {
cleanup();
}
}
finally {
await page.close();
}
}
finally {
await release();
}
}
/**
* Test multiple pages in parallel with redirect filtering
*/
async testMultiplePages(urls, options = {}) {
const startTime = Date.now();
const logger = this.logger.child ? this.logger.child('multi-test') : this.logger;
logger.info(`Testing ${urls.length} pages`, {
concurrency: options.maxConcurrent || constants_1.CONCURRENCY.DEFAULT_MAX,
comprehensive: this.shouldRunComprehensiveAnalysis(options),
skipRedirects: options.skipRedirects !== false
});
// Pre-filter redirects if enabled
const { urlsToProcess, skippedUrls } = await this.preFilterRedirects(urls, options, logger);
// Create and configure queue
const queue = this.createQueue(options, logger);
try {
// Process URLs with queue
const queueResult = await queue.processWithProgress(urlsToProcess, async (url) => {
return await this.testPage(url, options);
}, {
showProgress: !options.verbose,
progressInterval: constants_1.PROGRESS.UPDATE_INTERVAL
});
// Collect results
const results = this.collectResults(queueResult);
const totalDuration = Date.now() - startTime;
logger.info(`Multi-page testing completed`, {
total: urls.length,
tested: results.length,
skipped: skippedUrls.length,
failed: queueResult.failed.length,
totalDuration
});
return {
results,
skippedUrls,
totalDuration,
timestamp: new Date()
};
}
catch (error) {
logger.error('Multi-page testing failed', error);
throw error;
}
}
/**
* Pre-filter redirects from URL list
*/
async preFilterRedirects(urls, options, logger) {
const skipRedirects = options.skipRedirects !== false;
if (!skipRedirects) {
return { urlsToProcess: urls, skippedUrls: [] };
}
logger.debug('Pre-filtering redirects...');
const filteredUrls = [];
const skippedUrls = [];
for (const url of urls) {
try {
const minimalResult = await this.testUrlMinimal(url, 8000);
if (minimalResult.skipped && minimalResult.errors.some(e => e.includes('Redirect'))) {
skippedUrls.push(url);
logger.debug(`Skipped redirect: ${url}`);
}
else {
filteredUrls.push(url);
}
}
catch (error) {
// If minimal test fails, include URL for full testing
filteredUrls.push(url);
}
}
logger.info(`After redirect filtering: ${filteredUrls.length} URLs to test, ${skippedUrls.length} redirects skipped`);
return { urlsToProcess: filteredUrls, skippedUrls };
}
/**
* Create queue with callbacks
*/
createQueue(options, logger) {
const callbacks = {
onProgressUpdate: (stats) => {
if (stats.progress > 0 && stats.progress % constants_1.PROGRESS.REPORT_THRESHOLD === 0) {
logger.info(`Progress: ${stats.progress.toFixed(1)}%`, {
completed: stats.completed,
total: stats.total,
workers: stats.activeWorkers
});
}
},
onItemCompleted: (item, result) => {
logger.debug(`Completed: ${item.data}`, { duration: item.duration });
},
onItemFailed: (item, error) => {
logger.warn(`Failed: ${item.data}`, { error, attempts: item.attempts });
},
onQueueEmpty: () => {
logger.info('All page tests completed');
}
};
return queue_1.Queue.forAccessibilityTesting('parallel', {
maxConcurrent: options.maxConcurrent || constants_1.CONCURRENCY.DEFAULT_MAX,
maxRetries: constants_1.RETRY.MAX_ATTEMPTS,
retryDelay: constants_1.RETRY.DELAY_MS,
timeout: options.timeout || constants_1.TIMEOUTS.QUEUE_ITEM,
enableProgressReporting: true,
progressUpdateInterval: constants_1.PROGRESS.UPDATE_INTERVAL
}, callbacks);
}
/**
* Collect results from queue execution
*/
collectResults(queueResult) {
const results = [];
// Add successful results
queueResult.completed.forEach((item) => {
if (item.result) {
results.push(item.result);
}
});
// Add failed items as error results
queueResult.failed.forEach((failedItem) => {
const duration = failedItem.duration || 0;
results.push(result_factory_1.ResultFactory.createErrorResult(failedItem.data, failedItem.error, duration));
});
return results;
}
/**
* Test URL for basic connectivity (used by SmartUrlSampler)
*/
async testUrlMinimal(url, timeout = constants_1.TIMEOUTS.MINIMAL_TEST) {
const startTime = Date.now();
const logger = this.logger.child ? this.logger.child('minimal-test') : this.logger;
logger.debug(`Minimal test: ${url}`);
const { browser, context, release } = await this.config.poolManager.acquire();
try {
const page = await context.newPage();
try {
page.setDefaultTimeout(timeout);
// Set up redirect detection
const redirectDetector = (0, redirect_detector_1.createRedirectDetector)({
logger: logger.child ? logger.child('redirect') : logger
});
const { getResult, cleanup } = redirectDetector.attachToPage(page);
try {
const response = await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout
});
const title = await page.title();
const status = response?.status() || 0;
const duration = Date.now() - startTime;
// Get redirect information
const redirectInfo = getResult(response, url);
// Create base result
let result = result_factory_1.ResultFactory.createMinimalResult({
url,
title,
duration
});
// Update based on HTTP status
if (status === constants_1.HTTP_STATUS.NOT_FOUND) {
result = result_factory_1.ResultFactory.create404Result(url, duration);
}
else if ((0, constants_1.isHttpError)(status)) {
result = result_factory_1.ResultFactory.createHttpErrorResult(url, status, duration);
}
else if (redirectInfo.isRedirect) {
result = result_factory_1.ResultFactory.createHttpErrorResult(url, redirectInfo.statusCode, duration);
result = result_factory_1.ResultFactory.addRedirectInfo(result, redirectInfo);
}
else if ((0, constants_1.isHttpSuccess)(status)) {
result.passed = true;
result.title = title;
}
return result;
}
finally {
cleanup();
}
}
finally {
await page.close();
}
}
catch (error) {
logger.debug(`Minimal test failed: ${url}`, error);
const duration = Date.now() - startTime;
const result = result_factory_1.ResultFactory.createMinimalResult({ url, duration });
result.errors.push(`Navigation failed: ${error instanceof Error ? error.message : String(error)}`);
result.passed = false;
result.crashed = true;
return result;
}
finally {
await release();
}
}
/**
* Get available analysis types
*/
getAvailableAnalysisTypes() {
if (!this.analysisOrchestrator) {
return [];
}
return this.analysisOrchestrator.getAvailableAnalyzers();
}
// Private helper methods
/**
* Check if comprehensive analysis should be run
*/
shouldRunComprehensiveAnalysis(options) {
return ((options.enableComprehensiveAnalysis ?? this.config.enableComprehensiveAnalysis) &&
this.analysisOrchestrator !== undefined);
}
/**
* Run comprehensive analysis on a page
*/
async runComprehensiveAnalysis(page, url, options) {
if (!this.analysisOrchestrator) {
return undefined;
}
try {
return await this.analysisOrchestrator.runComprehensiveAnalysis(page, url, { timeout: options.timeout || constants_1.TIMEOUTS.DEFAULT_NAVIGATION });
}
catch (error) {
this.logger.warn('Comprehensive analysis failed', { url, error });
return undefined;
}
}
/**
* Configure page settings before navigation
*/
async configurePage(page, options) {
// Set viewport
await page.setViewportSize(constants_1.VIEWPORT.DESKTOP);
// Set user agent
await page.setExtraHTTPHeaders({
'User-Agent': constants_1.USER_AGENTS.DEFAULT
});
// Configure console/error logging based on verbose setting
if (options.verbose) {
page.on('console', msg => this.logger.debug(`Browser: ${msg.text()}`));
page.on('pageerror', error => this.logger.warn(`JS Error: ${error.message}`));
}
}
async runBasicAccessibilityAnalysis(page, url, options) {
const startTime = Date.now();
const result = {
url,
title: await page.title(),
imagesWithoutAlt: 0,
buttonsWithoutLabel: 0,
headingsCount: 0,
errors: [],
warnings: [],
passed: true,
duration: 0
};
// Basic element checks
result.imagesWithoutAlt = await page.locator('img:not([alt])').count();
if (result.imagesWithoutAlt > 0) {
result.warnings.push(`${result.imagesWithoutAlt} images without alt attribute`);
}
result.buttonsWithoutLabel = await page
.locator('button:not([aria-label])')
.filter({ hasText: '' })
.count();
if (result.buttonsWithoutLabel > 0) {
result.warnings.push(`${result.buttonsWithoutLabel} buttons without aria-label`);
}
result.headingsCount = await page.locator('h1, h2, h3, h4, h5, h6').count();
if (result.headingsCount === 0) {
result.errors.push('No headings found');
}
// Run pa11y tests
await this.runPa11yTests(result, options, page);
result.duration = Date.now() - startTime;
result.passed = result.errors.length === 0;
return result;
}
/**
* Run pa11y accessibility tests
*/
async runPa11yTests(result, options, page) {
try {
this.logger.debug('Running pa11y accessibility tests');
const pa11yResult = await (0, pa11y_1.default)(result.url, {
timeout: options.timeout || constants_1.TIMEOUTS.PA11Y_TEST,
wait: options.wait || constants_1.TIMEOUTS.PA11Y_WAIT,
standard: options.pa11yStandard || 'WCAG2AA',
hideElements: options.hideElements || constants_1.PA11Y_HIDE_ELEMENTS,
includeNotices: options.includeNotices !== false,
includeWarnings: options.includeWarnings !== false,
runners: ['axe'] // Always use axe for pooled browsers
});
// Process pa11y issues
if (pa11yResult.issues) {
this.processPa11yIssues(pa11yResult.issues, result);
result.pa11yScore = this.calculatePa11yScore(pa11yResult.issues);
}
else {
result.pa11yScore = 100;
}
}
catch (error) {
this.logger.warn('pa11y test failed, using fallback scoring', error);
result.pa11yScore = this.calculateFallbackScore(result);
}
}
/**
* Process pa11y issues and add them to result
*/
processPa11yIssues(issues, result) {
issues.forEach((issue) => {
const detailedIssue = {
code: issue.code,
message: issue.message,
type: issue.type,
selector: issue.selector,
context: issue.context,
impact: issue.impact,
help: issue.help,
helpUrl: issue.helpUrl
};
result.pa11yIssues = result.pa11yIssues || [];
result.pa11yIssues.push(detailedIssue);
// Add to appropriate array
const message = `${issue.code}: ${issue.message}`;
if (issue.type === 'error') {
result.errors.push(message);
}
else if (issue.type === 'warning') {
result.warnings.push(message);
}
else if (issue.type === 'notice') {
result.warnings.push(`Notice: ${message}`);
}
});
}
/**
* Calculate pa11y score based on issues
*/
calculatePa11yScore(issues) {
const errors = issues.filter((i) => i.type === 'error').length;
const warnings = issues.filter((i) => i.type === 'warning').length;
let score = 100;
score -= errors * constants_1.SCORING.ERROR_PENALTY;
score -= warnings * constants_1.SCORING.WARNING_PENALTY;
return Math.max(0, Math.min(100, Math.round(score)));
}
/**
* Calculate fallback score when pa11y fails
*/
calculateFallbackScore(result) {
let score = 100;
score -= result.errors.length * constants_1.SCORING.FALLBACK.ERROR_PENALTY;
score -= result.warnings.length * constants_1.SCORING.FALLBACK.WARNING_PENALTY;
score -= result.imagesWithoutAlt * constants_1.SCORING.FALLBACK.IMAGE_NO_ALT;
score -= result.buttonsWithoutLabel * constants_1.SCORING.FALLBACK.BUTTON_NO_LABEL;
if (result.headingsCount === 0) {
score -= constants_1.SCORING.FALLBACK.NO_HEADINGS;
}
return Math.max(0, score);
}
}
exports.AccessibilityChecker = AccessibilityChecker;
/**
* Error classes
*/
class AccessibilityTestError extends Error {
constructor(url, message) {
super(`Accessibility test failed for ${url}: ${message}`);
this.name = 'AccessibilityTestError';
}
}
exports.AccessibilityTestError = AccessibilityTestError;
//# sourceMappingURL=accessibility-checker.js.map