initial wcag-ada

This commit is contained in:
Boki 2025-06-28 11:11:34 -04:00
parent 042b8cb83a
commit d52cfe7de2
112 changed files with 9069 additions and 0 deletions

View file

@ -0,0 +1,162 @@
# WCAG-ADA Accessibility Scanner
A high-performance accessibility scanner built with Playwright and axe-core for WCAG compliance testing.
## Features
- 🚀 Fast scanning with Playwright
- ♿ WCAG 2.0, 2.1, and 2.2 compliance testing
- 📊 Detailed violation reports with fix suggestions
- 📸 Screenshot capture for violations
- 🔐 Authentication support (basic, form-based, custom)
- 📱 Mobile and desktop viewport testing
- 🎯 Custom rule support
- 🚫 Resource blocking for faster scans
## Installation
```bash
bun install
```
## Usage
### Basic Example
```typescript
import { AccessibilityScanner } from '@wcag-ada/scanner';
const scanner = new AccessibilityScanner();
await scanner.initialize();
const result = await scanner.scan({
url: 'https://example.com',
wcagLevel: {
level: 'AA',
version: '2.1'
}
});
console.log(`Compliance Score: ${result.summary.score}%`);
console.log(`Violations Found: ${result.summary.violationCount}`);
await scanner.close();
```
### Advanced Options
```typescript
const result = await scanner.scan({
url: 'https://example.com',
wcagLevel: {
level: 'AA',
version: '2.1'
},
includeScreenshots: true,
viewport: {
width: 1920,
height: 1080,
isMobile: false
},
authenticate: {
type: 'form',
loginUrl: 'https://example.com/login',
credentials: {
username: 'user@example.com',
password: 'password'
},
selectors: {
username: '#email',
password: '#password',
submit: 'button[type="submit"]'
}
},
excludeSelectors: [
'.cookie-banner',
'[aria-hidden="true"]'
],
waitForSelector: '#main-content',
timeout: 60000
});
```
### Scan Multiple Pages
```typescript
const urls = [
'https://example.com',
'https://example.com/about',
'https://example.com/contact'
];
const results = await scanner.scanMultiplePages(urls, {
wcagLevel: { level: 'AA', version: '2.1' }
});
```
## Scan Results
The scanner returns a comprehensive `AccessibilityScanResult` object containing:
- **Summary**: Overall compliance score, violation counts by severity
- **Violations**: Detailed list of accessibility issues with:
- Impact level (critical, serious, moderate, minor)
- Affected elements with HTML snippets
- Fix suggestions
- WCAG criteria mapping
- Screenshots (if enabled)
- **Passes**: Rules that passed
- **Incomplete**: Rules that need manual review
- **Page Metadata**: Title, language, element counts, landmark presence
- **WCAG Compliance**: Level-specific compliance status
## Performance Optimization
The scanner is optimized for speed:
- Blocks unnecessary resources (images, fonts, analytics)
- Parallel page scanning support
- Configurable timeouts
- Headless browser mode
## Custom Rules
Add custom accessibility rules:
```typescript
const customRules = [{
id: 'custom-button-size',
selector: 'button',
tags: ['custom', 'wcag21aa'],
description: 'Buttons must have minimum touch target size',
help: 'Ensure buttons are at least 44x44 pixels',
severity: 'serious',
validator: (element) => {
const rect = element.getBoundingClientRect();
return rect.width >= 44 && rect.height >= 44;
}
}];
const result = await scanner.scan({
url: 'https://example.com',
customRules
});
```
## Running the Example
```bash
bun run src/example.ts
```
## Development
```bash
# Build
bun run build
# Watch mode
bun run dev
# Type checking
bun run typecheck
```

View file

@ -0,0 +1,29 @@
{
"name": "@wcag-ada/scanner",
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -b",
"dev": "tsc -w",
"test": "playwright test",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@axe-core/playwright": "^4.8.3",
"@playwright/test": "^1.40.1",
"axe-core": "^4.8.3",
"playwright": "^1.40.1",
"pdfkit": "^0.14.0",
"handlebars": "^4.7.8",
"p-queue": "^7.4.1",
"@wcag-ada/shared": "workspace:*",
"@wcag-ada/config": "workspace:*"
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/pdfkit": "^0.13.4",
"typescript": "^5.3.3"
}
}

View file

@ -0,0 +1,285 @@
import { chromium, Browser, BrowserContext, Page } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import type { AxeResults } from 'axe-core';
import type {
AccessibilityScanOptions,
ViewportSize,
AuthenticationConfig
} from '@wcag-ada/shared';
import { getWCAGTags, RESOURCE_BLOCK_PATTERNS } from '@wcag-ada/shared';
import { getScannerConfig, type ScannerConfig } from '@wcag-ada/config';
export interface AccessibilityBrowserOptions {
headless?: boolean;
scanTimeout?: number;
defaultViewport?: ViewportSize;
blockResources?: boolean;
}
export class AccessibilityBrowser {
private browser?: Browser;
private context?: BrowserContext;
private logger: any;
private config: ScannerConfig;
private options: AccessibilityBrowserOptions;
private initialized = false;
constructor(logger?: any, options?: AccessibilityBrowserOptions) {
this.logger = logger || console;
this.config = getScannerConfig();
// Merge config with provided options
this.options = {
headless: options?.headless ?? this.config.headless,
scanTimeout: options?.scanTimeout ?? this.config.timeout,
defaultViewport: options?.defaultViewport ?? this.config.viewport,
blockResources: options?.blockResources ?? this.config.blockResources,
};
}
async initialize(): Promise<void> {
if (this.initialized) return;
this.browser = await chromium.launch({
headless: this.options.headless,
args: this.config.browsers.chromium.args,
});
this.context = await this.browser.newContext({
viewport: this.options.defaultViewport,
});
// Block resources for faster scanning if enabled
if (this.options.blockResources) {
await this.context.route('**/*', (route) => {
const url = route.request().url();
const shouldBlock = RESOURCE_BLOCK_PATTERNS.some(pattern => {
if (pattern.includes('*')) {
const regex = new RegExp(pattern.replace('*', '.*'));
return regex.test(url);
}
return url.includes(pattern);
});
if (shouldBlock) {
route.abort();
} else {
route.continue();
}
});
}
this.initialized = true;
}
async close(): Promise<void> {
if (this.context) await this.context.close();
if (this.browser) await this.browser.close();
this.initialized = false;
}
async scanPage(options: AccessibilityScanOptions): Promise<{
page: Page;
axeResults: AxeResults;
html: string;
}> {
if (!this.context) throw new Error('Browser not initialized');
const { url, viewport, authenticate, wcagLevel, excludeSelectors, waitForSelector } = options;
const page = await this.context.newPage();
try {
// Set viewport if different from default
if (viewport) {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
}
// Handle authentication if needed
if (authenticate) {
await this.handleAuthentication(page, authenticate);
}
// Navigate to URL
await page.goto(url, {
waitUntil: 'networkidle',
timeout: options.timeout || this.config.pageLoadTimeout,
});
// Wait for specific selector if provided
if (waitForSelector) {
await page.waitForSelector(waitForSelector, {
timeout: 10000,
});
}
// Additional wait for dynamic content
await page.waitForTimeout(2000);
// Get page HTML
const html = await page.content();
// Configure axe
const axeBuilder = new AxeBuilder({ page });
// Set WCAG tags
if (wcagLevel) {
const tags = getWCAGTags(wcagLevel);
axeBuilder.withTags(tags);
}
// Exclude selectors if provided
if (excludeSelectors && excludeSelectors.length > 0) {
excludeSelectors.forEach(selector => {
axeBuilder.exclude(selector);
});
}
// Set options
axeBuilder.options({
resultTypes: this.config.axe.resultTypes,
elementRef: true,
runOnly: {
type: 'tag',
values: wcagLevel ? getWCAGTags(wcagLevel) : this.config.axe.tags,
},
});
// Run accessibility scan
const axeResults = await Promise.race([
axeBuilder.analyze(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Scan timeout')), this.options.scanTimeout!)
),
]);
return {
page,
axeResults,
html,
};
} catch (error) {
await page.close();
throw error;
}
}
private async handleAuthentication(page: Page, auth: AuthenticationConfig): Promise<void> {
switch (auth.type) {
case 'basic':
if (auth.credentials?.username && auth.credentials?.password) {
await page.context().setHTTPCredentials({
username: auth.credentials.username,
password: auth.credentials.password,
});
}
break;
case 'form':
if (auth.loginUrl) {
await page.goto(auth.loginUrl);
if (auth.selectors?.username && auth.credentials?.username) {
await page.fill(auth.selectors.username, auth.credentials.username);
}
if (auth.selectors?.password && auth.credentials?.password) {
await page.fill(auth.selectors.password, auth.credentials.password);
}
if (auth.selectors?.submit) {
await page.click(auth.selectors.submit);
await page.waitForNavigation({ waitUntil: 'networkidle' });
}
}
break;
case 'custom':
if (auth.customScript) {
await page.evaluate(auth.customScript);
}
break;
}
}
async captureViolationScreenshots(
page: Page,
violations: AxeResults['violations']
): Promise<Map<string, string>> {
const screenshots = new Map<string, string>();
for (const violation of violations) {
for (let i = 0; i < violation.nodes.length; i++) {
const node = violation.nodes[i];
if (node.target && node.target.length > 0) {
try {
const selector = node.target[0];
const element = await page.$(selector);
if (element) {
const screenshot = await element.screenshot({
type: 'png',
});
const key = `${violation.id}_${i}`;
screenshots.set(key, screenshot.toString('base64'));
}
} catch (error) {
this.logger?.warn(`Failed to capture screenshot for ${violation.id}:`, error);
}
}
}
}
return screenshots;
}
async evaluateCustomRules(page: Page, customRules: any[]): Promise<any[]> {
const results = [];
for (const rule of customRules) {
try {
const elements = await page.$$(rule.selector);
const violations = [];
for (const element of elements) {
const passes = await page.evaluate(
(el, validatorStr) => {
const validator = new Function('element', `return (${validatorStr})(element)`);
return validator(el);
},
element,
rule.validator.toString()
);
if (!passes) {
const html = await element.evaluate(el => el.outerHTML);
violations.push({
html,
target: [rule.selector],
});
}
}
if (violations.length > 0) {
results.push({
id: rule.id,
description: rule.description,
help: rule.help,
helpUrl: rule.helpUrl,
impact: rule.severity,
tags: rule.tags,
nodes: violations,
});
}
} catch (error) {
this.logger?.error(`Failed to evaluate custom rule ${rule.id}:`, error);
}
}
return results;
}
}

View file

@ -0,0 +1,303 @@
import { AccessibilityBrowser } from './accessibility-browser';
import type {
AccessibilityScanOptions,
AccessibilityScanResult,
AccessibilityViolation,
ScanSummary,
PageMetadata,
WCAGCompliance,
WCAGLevel,
CriteriaResult
} from '@wcag-ada/shared';
import {
calculateComplianceScore,
summarizeViolations,
generateFixSuggestion,
extractPageMetadata,
isCompliantWithLevel,
CRITICAL_WCAG_CRITERIA
} from '@wcag-ada/shared';
import type { AxeResults, Result as AxeResult } from 'axe-core';
import { initializeWcagConfig } from '@wcag-ada/config';
export class AccessibilityScanner {
private browser: AccessibilityBrowser;
private logger: any;
constructor(logger?: any) {
this.logger = logger || console;
// Initialize config for scanner service
initializeWcagConfig();
this.browser = new AccessibilityBrowser(logger);
}
async initialize(): Promise<void> {
await this.browser.initialize();
}
async close(): Promise<void> {
await this.browser.close();
}
async scan(options: AccessibilityScanOptions): Promise<AccessibilityScanResult> {
const startTime = Date.now();
try {
// Perform the scan
const { page, axeResults, html } = await this.browser.scanPage(options);
// Capture screenshots if requested
let screenshotMap: Map<string, string> | undefined;
if (options.includeScreenshots) {
screenshotMap = await this.browser.captureViolationScreenshots(page, axeResults.violations);
}
// Evaluate custom rules if provided
let customViolations: AxeResult[] = [];
if (options.customRules && options.customRules.length > 0) {
customViolations = await this.browser.evaluateCustomRules(page, options.customRules);
}
// Close the page
await page.close();
// Process results
const violations = this.processViolations(
[...axeResults.violations, ...customViolations],
screenshotMap
);
// Extract metadata
const pageMetadata = this.extractPageMetadata(html, options);
// Calculate summary
const summary = this.calculateSummary(axeResults, violations);
// Determine WCAG compliance
const wcagCompliance = this.calculateWCAGCompliance(
violations,
axeResults.passes,
options.wcagLevel || { level: 'AA', version: '2.1' },
summary.score
);
const scanDuration = Date.now() - startTime;
return {
url: options.url,
timestamp: new Date(),
scanDuration,
summary,
violations,
passes: axeResults.passes,
incomplete: axeResults.incomplete,
inapplicable: axeResults.inapplicable,
pageMetadata,
wcagCompliance,
};
} catch (error) {
this.logger.error('Scan failed:', error);
throw error;
}
}
private processViolations(
axeViolations: AxeResult[],
screenshots?: Map<string, string>
): AccessibilityViolation[] {
return axeViolations.map(violation => {
const nodes = violation.nodes.map((node, index) => {
const screenshotKey = `${violation.id}_${index}`;
return {
html: node.html,
target: node.target as string[],
xpath: node.xpath?.[0],
failureSummary: node.failureSummary,
screenshot: screenshots?.get(screenshotKey),
relatedNodes: node.relatedNodes?.map(related => ({
html: related.html,
target: related.target as string[],
})),
};
});
return {
id: violation.id,
impact: violation.impact!,
description: violation.description,
help: violation.help,
helpUrl: violation.helpUrl,
tags: violation.tags,
nodes,
wcagCriteria: this.mapToWCAGCriteria(violation.tags),
fixSuggestion: generateFixSuggestion(violation.id),
};
});
}
private extractPageMetadata(html: string, options: AccessibilityScanOptions): PageMetadata {
const metadata = extractPageMetadata(html);
return {
title: metadata.title || 'Untitled Page',
url: options.url,
viewport: options.viewport || { width: 1280, height: 720 },
language: metadata.language,
doctype: this.extractDoctype(html),
hasLandmarks: metadata.hasLandmarks || false,
hasHeadings: metadata.hasHeadings || false,
imagesCount: metadata.imagesCount || 0,
formsCount: metadata.formsCount || 0,
linksCount: metadata.linksCount || 0,
};
}
private extractDoctype(html: string): string {
const doctypeMatch = html.match(/<!DOCTYPE\s+([^>]+)>/i);
return doctypeMatch ? doctypeMatch[1].trim() : 'unknown';
}
private calculateSummary(
axeResults: AxeResults,
violations: AccessibilityViolation[]
): ScanSummary {
const violationSummary = summarizeViolations(violations);
const violationCount = violations.reduce((sum, v) => sum + v.nodes.length, 0);
const result: AccessibilityScanResult = {
violations,
passes: axeResults.passes,
summary: {} as ScanSummary,
} as AccessibilityScanResult;
const score = calculateComplianceScore(result);
return {
violationCount,
passCount: axeResults.passes.length,
incompleteCount: axeResults.incomplete.length,
inapplicableCount: axeResults.inapplicable.length,
...violationSummary,
score,
} as ScanSummary;
}
private calculateWCAGCompliance(
violations: AccessibilityViolation[],
passes: AxeResult[],
wcagLevel: WCAGLevel,
score: number
): WCAGCompliance {
const criteriaMap = new Map<string, CriteriaResult>();
// Initialize critical criteria
CRITICAL_WCAG_CRITERIA.forEach(criterion => {
criteriaMap.set(criterion, {
criterion,
level: this.getCriterionLevel(criterion),
passed: true,
violations: [],
});
});
// Process violations
violations.forEach(violation => {
violation.wcagCriteria?.forEach(criterion => {
if (!criteriaMap.has(criterion)) {
criteriaMap.set(criterion, {
criterion,
level: this.getCriterionLevel(criterion),
passed: false,
violations: [violation.id],
});
} else {
const result = criteriaMap.get(criterion)!;
result.passed = false;
result.violations.push(violation.id);
}
});
});
const criteriaResults = Array.from(criteriaMap.values());
const isCompliant = isCompliantWithLevel(score, wcagLevel.level);
return {
level: wcagLevel,
isCompliant,
criteriaResults,
overallScore: score,
};
}
private mapToWCAGCriteria(tags: string[]): string[] {
const criteriaMap: Record<string, string[]> = {
'wcag111': ['1.1.1'],
'wcag131': ['1.3.1'],
'wcag141': ['1.4.1'],
'wcag143': ['1.4.3'],
'wcag211': ['2.1.1'],
'wcag212': ['2.1.2'],
'wcag241': ['2.4.1'],
'wcag242': ['2.4.2'],
'wcag243': ['2.4.3'],
'wcag244': ['2.4.4'],
'wcag311': ['3.1.1'],
'wcag331': ['3.3.1'],
'wcag332': ['3.3.2'],
'wcag411': ['4.1.1'],
'wcag412': ['4.1.2'],
};
const criteria = new Set<string>();
tags.forEach(tag => {
const mapped = criteriaMap[tag];
if (mapped) {
mapped.forEach(c => criteria.add(c));
}
});
return Array.from(criteria);
}
private getCriterionLevel(criterion: string): 'A' | 'AA' | 'AAA' {
const levelMap: Record<string, 'A' | 'AA' | 'AAA'> = {
'1.1.1': 'A',
'1.3.1': 'A',
'1.4.1': 'A',
'1.4.3': 'AA',
'2.1.1': 'A',
'2.1.2': 'A',
'2.4.1': 'A',
'2.4.2': 'A',
'2.4.3': 'A',
'2.4.4': 'A',
'3.1.1': 'A',
'3.3.1': 'A',
'3.3.2': 'A',
'4.1.1': 'A',
'4.1.2': 'A',
};
return levelMap[criterion] || 'A';
}
async scanMultiplePages(
urls: string[],
baseOptions: Omit<AccessibilityScanOptions, 'url'>
): Promise<AccessibilityScanResult[]> {
const results: AccessibilityScanResult[] = [];
for (const url of urls) {
try {
const result = await this.scan({ ...baseOptions, url });
results.push(result);
} catch (error) {
this.logger.error(`Failed to scan ${url}:`, error);
}
}
return results;
}
}

View file

@ -0,0 +1,79 @@
import { AccessibilityScanner } from './core/scanner';
import type { AccessibilityScanOptions } from '@wcag-ada/shared';
import { createLogger } from '@lib/service/core-logger';
const logger = createLogger('scanner-example');
async function runExample() {
const scanner = new AccessibilityScanner();
try {
// Initialize the browser
await scanner.initialize();
// Define scan options
const scanOptions: AccessibilityScanOptions = {
url: 'https://example.com',
wcagLevel: {
level: 'AA',
version: '2.1'
},
includeScreenshots: true,
viewport: {
width: 1920,
height: 1080
},
excludeSelectors: [
'.cookie-banner',
'[aria-hidden="true"]'
]
};
logger.info('Starting accessibility scan...');
const result = await scanner.scan(scanOptions);
// Display summary
logger.info('\n=== SCAN SUMMARY ===');
logger.info(`URL: ${result.url}`);
logger.info(`Compliance Score: ${result.summary.score}%`);
logger.info(`WCAG ${result.wcagCompliance.level.version} Level ${result.wcagCompliance.level.level}: ${result.wcagCompliance.isCompliant ? 'PASSED' : 'FAILED'}`);
logger.info(`\nViolations: ${result.summary.violationCount}`);
logger.info(`- Critical: ${result.summary.criticalIssues}`);
logger.info(`- Serious: ${result.summary.seriousIssues}`);
logger.info(`- Moderate: ${result.summary.moderateIssues}`);
logger.info(`- Minor: ${result.summary.minorIssues}`);
// Display top violations
if (result.violations.length > 0) {
logger.info('\n=== TOP VIOLATIONS ===');
result.violations.slice(0, 5).forEach((violation, index) => {
logger.info(`\n${index + 1}. ${violation.help}`);
logger.info(` Impact: ${violation.impact}`);
logger.info(` Affected elements: ${violation.nodes.length}`);
logger.info(` Fix: ${violation.fixSuggestion}`);
logger.info(` More info: ${violation.helpUrl}`);
});
}
// Display page metadata
logger.info('\n=== PAGE METADATA ===');
logger.info(`Title: ${result.pageMetadata.title}`);
logger.info(`Language: ${result.pageMetadata.language || 'Not specified'}`);
logger.info(`Images: ${result.pageMetadata.imagesCount}`);
logger.info(`Forms: ${result.pageMetadata.formsCount}`);
logger.info(`Links: ${result.pageMetadata.linksCount}`);
logger.info(`Has landmarks: ${result.pageMetadata.hasLandmarks ? 'Yes' : 'No'}`);
logger.info(`Has headings: ${result.pageMetadata.hasHeadings ? 'Yes' : 'No'}`);
} catch (error) {
logger.error('Scan failed:', error);
} finally {
// Always close the browser
await scanner.close();
}
}
// Run the example
if (require.main === module) {
runExample().catch((error) => logger.error('Example failed:', error));
}

View file

@ -0,0 +1,14 @@
export { AccessibilityScanner } from './core/scanner';
export { AccessibilityBrowser } from './core/accessibility-browser';
export type { AccessibilityBrowserOptions } from './core/accessibility-browser';
// Re-export shared types for convenience
export type {
AccessibilityScanOptions,
AccessibilityScanResult,
AccessibilityViolation,
WCAGLevel,
Website,
ScanJob,
ComplianceReport,
} from '@wcag-ada/shared';

View file

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"],
"references": [
{ "path": "../shared" }
]
}