285 lines
No EOL
8.1 KiB
TypeScript
285 lines
No EOL
8.1 KiB
TypeScript
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;
|
|
}
|
|
} |