/a11y-ally - Comprehensive Accessibility Audit
SkillWeb & browsingComprehensive WCAG accessibility auditing with multi-tool testing (axe-core + pa11y + Lighthouse), TRUE PARALLEL execution with Promise.allSettled, graceful degradation, retry with backoff, context-aware remediation, learning integration, and video accessibility. Uses 3-tier browser cascade: Vibium → agent-browser → Playwright+Stealth.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the /a11y-ally - Comprehensive Accessibility Audit skill
What this skill tells your AI
The instructions your AI receives, as published by proffesor-for-testing/agentic-qe in .kiro/skills/qe-a11y-ally/SKILL.md and read by ahel’s review.
<default_to_action> When this skill is invoked with a URL, Claude executes ALL steps automatically without waiting for user prompts between steps.
THIS IS AN LLM-POWERED SKILL
The value of this skill is Claude's intelligence, not just running automated tools:
| Automated Tools Do | Claude (This Skill) Does |
|---|---|
| Flag "button has no name" | Analyze context: icon class, parent element, nearby text → generate "Add to wishlist" |
| Flag "image missing alt" | Use Vision to see the image → describe actual content |
| Flag "video has no captions" | Download video, extract frames, analyze each frame with Vision → generate real captions |
| Output generic templates | Generate context-specific, copy-paste ready fixes |
IF YOU SKIP THE LLM ANALYSIS, THIS SKILL HAS NO VALUE.
EXECUTION MODEL
CLAUDE EXECUTES ALL STEPS WITHOUT STOPPING.
Do NOT wait for user prompts between steps. Execute the full pipeline:
- Data Collection: Run multi-tool scan (axe-core, pa11y, Lighthouse) via Bash
- LLM Analysis: Read results and analyze context for each violation
- Vision Pipeline: If videos detected → download → extract frames → Read each frame → describe
- Intelligent Remediation: Generate context-specific fixes using your reasoning
- Generate Reports: Write all output files to
docs/accessibility-scans/{page-slug}/
WRONG:
Claude: "I found 5 violations. Should I analyze them?"
User: "Yes"
Claude: "I see a video. Should I run the video pipeline?"
User: "Yes"
RIGHT:
Claude: [Runs scan] → [Analyzes violations] → [Downloads video] → [Extracts frames] →
[Reads each frame with Vision] → [Generates captions] → [Writes all files]
"Audit complete. Generated 4 files in docs/accessibility-scans/example/"
STEP 1: BROWSER AUTOMATION - Content Fetching
1.1: Try VIBIUM First (Primary)
ToolSearch("select:mcp__vibium__browser_launch")
ToolSearch("select:mcp__vibium__browser_navigate")
mcp__vibium__browser_launch({ headless: true })
mcp__vibium__browser_navigate({ url: "TARGET_URL" })
If Vibium fails → Go to STEP 1b
1b: Try AGENT-BROWSER Fallback
ToolSearch("select:mcp__claude-flow_alpha__browser_open")
mcp__claude-flow_alpha__browser_open({ url: "TARGET_URL", waitUntil: "networkidle" })
If agent-browser fails → Go to STEP 1c
1c: PLAYWRIGHT + STEALTH (Final Fallback)
mkdir -p /tmp/a11y-work && cd /tmp/a11y-work
npm init -y 2>/dev/null
npm install playwright-extra puppeteer-extra-plugin-stealth @axe-core/playwright pa11y lighthouse chrome-launcher 2>/dev/null
Create and run scan script - see STEP 2 for full multi-tool scan code.
1d: PARALLEL MULTI-PAGE AUDIT (Optional)
For auditing multiple URLs simultaneously, use parallel execution:
// /tmp/a11y-work/parallel-audit.js
const { chromium } = require('playwright-extra');
const stealth = require('puppeteer-extra-plugin-stealth')();
const { AxeBuilder } = require('@axe-core/playwright');
chromium.use(stealth);
const MAX_CONCURRENT = 6; // Maximum parallel auditors
async function auditUrl(browser, url) {
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForTimeout(2000);
const axeResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
return { url, success: true, violations: axeResults.violations };
} catch (error) {
return { url, success: false, error: error.message };
} finally {
await context.close();
}
}
async function parallelAudit(urls) {
const browser = await chromium.launch({ headless: true });
const results = [];
// Process in chunks of MAX_CONCURRENT
for (let i = 0; i < urls.length; i += MAX_CONCURRENT) {
const chunk = urls.slice(i, i + MAX_CONCURRENT);
console.log(`Auditing batch ${Math.floor(i/MAX_CONCURRENT) + 1}: ${chunk.length} URLs`);
const chunkResults = await Promise.all(
chunk.map(url => auditUrl(browser, url))
);
results.push(...chunkResults);
}
await browser.close();
return results;
}
// Usage: node parallel-audit.js url1 url2 url3 ...
const urls = process.argv.slice(2);
if (urls.length > 0) {
parallelAudit(urls).then(results => {
console.log(JSON.stringify(results, null, 2));
});
}
Usage for multi-page audit:
node parallel-audit.js https://example.com https://example.com/about https://example.com/contact
1e: SITE CRAWL MODE (Optional)
For comprehensive site audits, crawl and audit all pages:
// /tmp/a11y-work/crawl-audit.js
async function crawlAndAudit(startUrl, maxPages = 50) {
const browser = await chromium.launch({ headless: true });
const visited = new Set();
const toVisit = [startUrl];
const results = [];
const baseUrl = new URL(startUrl).origin;
while (toVisit.length > 0 && results.length < maxPages) {
const url = toVisit.shift();
if (visited.has(url)) continue;
visited.add(url);
console.log(`[${results.length + 1}/${maxPages}] Auditing: ${url}`);
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Extract same-domain links for crawling
const links = await page.evaluate((base) => {
return [...document.querySelectorAll('a[href]')]
.map(a => a.href)
.filter(href => href.startsWith(base) && !href.includes('#'))
.filter(href => !href.match(/\.(pdf|jpg|png|gif|css|js)$/i));
}, baseUrl);
// Add new links to queue
links.forEach(link => {
if (!visited.has(link) && !toVisit.includes(link)) {
toVisit.push(link);
}
});
// Run accessibility audit
const axeResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
results.push({ url, violations: axeResults.violations });
} catch (e) {
results.push({ url, error: e.message });
}
await context.close();
}
await browser.close();
return { pagesAudited: results.length, results };
}
// Usage: node crawl-audit.js https://example.com 50
const [startUrl, maxPages] = process.argv.slice(2);
crawlAndAudit(startUrl, parseInt(maxPages) || 50).then(r => console.log(JSON.stringify(r, null, 2)));
STEP 2: COMPREHENSIVE WCAG SCAN (Multi-Tool, Parallel, Resilient)
IMPORTANT: This step uses THREE accessibility testing tools for maximum coverage:
- axe-core: Industry standard, excellent for ARIA and semantic issues
- pa11y: Strong on contrast, links, and HTML validation
- Lighthouse: Google's accessibility scoring with performance correlation
Combined detection rate is ~15% higher than any single tool.
2.0: RESILIENCE ARCHITECTURE (v7.0 Enhancement)
Key improvements over v6.0:
| Feature | v6.0 (Old) | v7.0 (New) |
|---|---|---|
| Tool execution | Sequential | Parallel (Promise.allSettled) |
| Timeout handling | Global 60s | Per-tool (60s/60s/90s) |
| Failure mode | All-or-nothing | Graceful degradation |
| Retry logic | None | Exponential backoff (3 retries) |
| Output style | Wait for all | Progressive (stream as ready) |
| Minimum tools | 3 required | 1 of 3 sufficient |
Coverage by tools succeeded:
- 3/3 tools: ~95% detection (optimal)
- 2/3 tools: ~85% detection (good)
- 1/3 tools: ~70% detection (acceptable)
- 0/3 tools: FAIL - retry with different strategy
2.1: Run Multi-Tool Analysis (PARALLEL + RESILIENT)
Create and run /tmp/a11y-work/multi-tool-scan.js:
const { chromium } = require('playwright-extra');
const stealth = require('puppeteer-extra-plugin-stealth')();
const { AxeBuilder } = require('@axe-core/playwright');
const pa11y = require('pa11y');
const lighthouse = require('lighthouse').default || require('lighthouse');
const { launch: launchChrome } = require('chrome-launcher');
const fs = require('fs');
chromium.use(stealth);
const TARGET_URL = process.argv[2] || 'TARGET_URL';
const OUTPUT_FILE = '/tmp/a11y-work/scan-results.json';
const SYSTEM_CHROMIUM = '/usr/bin/chromium';
// ========== RESILIENCE UTILITIES ==========
// Timeout wrapper - wraps any promise with a timeout
function withTimeout(promise, ms, name) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${name} timed out after ${ms}ms`)), ms)
)
]);
}
// Retry wrapper - retries with exponential backoff
async function withRetry(fn, name, maxRetries = 3, baseDelay = 2000) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
const isLastAttempt = attempt === maxRetries;
console.log(`[${name}] Attempt ${attempt}/${maxRetries} failed: ${error.message}`);
if (isLastAttempt) throw error;
const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
console.log(`[${name}] Retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
// Sleep utility
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Progressive output - append results as they arrive
function progressiveOutput(tool, data) {
console.log(`\n=== ${tool.toUpperCase()} COMPLETE ===`);
console.log(JSON.stringify(data, null, 2));
// Append to results file for progressive access
try {
let results = {};
if (fs.existsSync(OUTPUT_FILE)) {
results = JSON.parse(fs.readFileSync(OUTPUT_FILE, 'utf8'));
}
results[tool] = data;
results.lastUpdated = new Date().toISOString();
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(results, null, 2));
} catch (e) { /* ignore file errors */ }
}
// ========== TOOL RUNNERS ==========
// TOOL 1: Axe-core (with page info extraction)
async function runAxeCore(url) {
console.log('[axe-core] Starting...');
const browser = await chromium.launch({
headless: true,
executablePath: SYSTEM_CHROMIUM,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-blink-features=AutomationControlled'
]
});
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
locale: 'en-US',
viewport: { width: 1920, height: 1080 }
});
const page = await context.newPage();
try {
// Use domcontentloaded (faster, more reliable than networkidle)
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Random delay to appear human
await sleep(2000 + Math.random() * 2000);
// Try to dismiss cookie banners
try {
const cookieSelectors = [
'button:has-text("Accept")', 'button:has-text("Akzeptieren")',
'button:has-text("Alle akzeptieren")', '[data-testid="cookie-accept"]',
'#onetrust-accept-btn-handler', '.cookie-consent-accept'
];
for (const selector of cookieSelectors) {
const btn = await page.$(selector);
if (btn) { await btn.click(); await sleep(500); break; }
}
} catch (e) { /* ignore cookie errors */ }
// Run axe-core analysis
const axeResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
// Extract comprehensive page info
const pageInfo = await page.evaluate(() => ({
title: document.title,
url: window.location.href,
lang: document.documentElement.lang,
images: {
total: document.querySelectorAll('img').length,
withAlt: document.querySelectorAll('img[alt]').length,
withoutAlt: document.querySelectorAll('img:not([alt])').length,
emptyAlt: document.querySelectorAll('img[alt=""]').length
},
headings: {
h1: Array.from(document.querySelectorAll('h1')).map(h => h.textContent.trim().slice(0,60)),
h2: document.querySelectorAll('h2').length,
h3: document.querySelectorAll('h3').length,
total: document.querySelectorAll('h1,h2,h3,h4,h5,h6').length
},
forms: {
total: document.querySelectorAll('form').length,
inputs: document.querySelectorAll('input, select, textarea').length,
buttons: document.querySelectorAll('button').length
},
links: { total: document.querySelectorAll('a').length },
aria: {
ariaLabels: document.querySelectorAll('[aria-label]').length,
roles: document.querySelectorAll('[role]').length
},
landmarks: {
main: document.querySelectorAll('main').length,
nav: document.querySelectorAll('nav').length,
header: document.querySelectorAll('header').length,
footer: document.querySelectorAll('footer').length
},
media: {
videos: document.querySelectorAll('video').length,
iframes: document.querySelectorAll('iframe').length,
videoUrls: Array.from(document.querySelectorAll('video')).map(v => {
const src = v.src || (v.querySelector('source') ? v.querySelector('source').src : '');
return {
src: src,
hasCaptions: !!v.querySelector('track[kind="captions"]')
};
})
}
}));
const violations = axeResults.violations.map(v => ({
tool: 'axe-core',
id: v.id,
impact: v.impact,
description: v.description,
help: v.help,
helpUrl: v.helpUrl,
tags: v.tags,
nodeCount: v.nodes.length,
nodes: v.nodes.slice(0, 5).map(n => ({
html: n.html.slice(0, 200),
target: n.target,
failureSummary: n.failureSummary
}))
}));
return {
success: true,
pageInfo,
violations,
passesCount: axeResults.passes.length
};
} finally {
await context.close();
await browser.close();
}
}
// TOOL 2: Pa11y
async function runPa11y(url) {
console.log('[pa11y] Starting...');
const results = await pa11y(url, {
standard: 'WCAG2AA',
timeout: 45000,
wait: 2000,
chromeLaunchConfig: {
executablePath: SYSTEM_CHROMIUM,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
}
});
const violations = results.issues.map(issue => ({
tool: 'pa11y',
id: issue.code,
impact: issue.type === 'error' ? 'serious' : issue.type === 'warning' ? 'moderate' : 'minor',
description: issue.message,
selector: issue.selector,
context: (issue.context || '').slice(0, 200)
}));
return { success: true, violations, total: results.issues.length };
}
// TOOL 3: Lighthouse
async function runLighthouse(url) {
console.log('[lighthouse] Starting...');
const chrome = await launchChrome({
chromePath: SYSTEM_CHROMIUM,
chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
});
try {
const result = await lighthouse(url, {
port: chrome.port,
onlyCategories: ['accessibility'],
output: 'json'
});
const lhr = result.lhr;
const score = Math.round(lhr.categories.accessibility.score * 100);
const violations = Object.values(lhr.audits)
.filter(audit => audit.score !== null && audit.score < 1)
.map(audit => ({
tool: 'lighthouse',
id: audit.id,
impact: audit.score === 0 ? 'critical' : audit.score < 0.5 ? 'serious' : 'moderate',
score: audit.score,
description: audit.title
}));
return { success: true, score, violations };
} finally {
await chrome.kill();
}
}
// ========== MAIN: PARALLEL EXECUTION WITH GRACEFUL DEGRADATION ==========
(async () => {
console.log('=== MULTI-TOOL ACCESSIBILITY SCAN (v7.0 PARALLEL + RESILIENT) ===');
console.log('Target:', TARGET_URL);
console.log('Strategy: Promise.allSettled with per-tool timeouts\n');
const startTime = Date.now();
// Run ALL tools in PARALLEL with individual timeouts
const [axeResult, pa11yResult, lighthouseResult] = await Promise.allSettled([
withTimeout(
withRetry(() => runAxeCore(TARGET_URL), 'axe-core', 2, 3000),
60000, 'axe-core'
),
withTimeout(
withRetry(() => runPa11y(TARGET_URL), 'pa11y', 2, 3000),
60000, 'pa11y'
),
withTimeout(
withRetry(() => runLighthouse(TARGET_URL), 'lighthouse', 2, 3000),
90000, 'lighthouse'
)
]);
// ========== PROCESS RESULTS (Graceful Degradation) ==========
const results = {
url: TARGET_URL,
timestamp: new Date().toISOString(),
duration: `${((Date.now() - startTime) / 1000).toFixed(1)}s`,
toolsSucceeded: 0,
toolsFailed: 0,
pageInfo: null,
violations: [],
byTool: {}
};
// Process axe-core results
if (axeResult.status === 'fulfilled') {
results.toolsSucceeded++;
results.pageInfo = axeResult.value.pageInfo;
results.violations.push(...axeResult.value.violations);
results.byTool['axe-core'] = {
success: true,
count: axeResult.value.violations.length,
passes: axeResult.value.passesCount
};
progressiveOutput('axe-core', axeResult.value);
} else {
results.toolsFailed++;
results.byTool['axe-core'] = { success: false, error: axeResult.reason.message };
console.log('\n[axe-core] FAILED:', axeResult.reason.message);
}
// Process pa11y results
if (pa11yResult.status === 'fulfilled') {
results.toolsSucceeded++;
results.violations.push(...pa11yResult.value.violations);
results.byTool['pa11y'] = {
success: true,
count: pa11yResult.value.violations.length
};
progressiveOutput('pa11y', pa11yResult.value);
} else {
results.toolsFailed++;
results.byTool['pa11y'] = { success: false, error: pa11yResult.reason.message };
console.log('\n[pa11y] FAILED:', pa11yResult.reason.message);
}
// Process lighthouse results
if (lighthouseResult.status === 'fulfilled') {
results.toolsSucceeded++;
results.violations.push(...lighthouseResult.value.violations);
results.byTool['lighthouse'] = {
success: true,
score: lighthouseResult.value.score,
count: lighthouseResult.value.violations.length
};
progressiveOutput('lighthouse', lighthouseResult.value);
} else {
results.toolsFailed++;
results.byTool['lighthouse'] = { success: false, error: lighthouseResult.reason.message };
console.log('\n[lighthouse] FAILED:', lighthouseResult.reason.message);
}
// ========== DEDUPLICATE VIOLATIONS ==========
const seen = new Set();
const uniqueViolations = [];
for (const v of results.violations) {
const key = (v.description || '').toLowerCase().slice(0, 50);
if (!seen.has(key)) {
seen.add(key);
uniqueViolations.push(v);
}
}
results.uniqueViolations = uniqueViolations;
results.totalUnique = uniqueViolations.length;
// ========== FINAL OUTPUT ==========
console.log('\n' + '='.repeat(60));
console.log('=== SCAN COMPLETE ===');
console.log('='.repeat(60));
console.log(`Tools succeeded: ${results.toolsSucceeded}/3`);
console.log(`Tools failed: ${results.toolsFailed}/3`);
console.log(`Duration: ${results.duration}`);
console.log(`Total unique violations: ${results.totalUnique}`);
if (results.toolsSucceeded === 0) {
console.log('\n⚠️ ALL TOOLS FAILED - Consider:');
console.log(' 1. Site may have strong bot protection');
console.log(' 2. Try Vibium MCP browser instead');
console.log(' 3. Check network connectivity');
} else if (results.toolsSucceeded < 3) {
console.log(`\n⚠️ Partial coverage (${results.toolsSucceeded}/3 tools) - Results still usable`);
} else {
console.log('\n✅ Full coverage achieved (3/3 tools)');
}
console.log('\n=== PAGE INFO ===');
console.log(JSON.stringify(results.pageInfo, null, 2));
console.log('\n=== VIOLATIONS BY TOOL ===');
console.log(JSON.stringify(results.byTool, null, 2));
console.log('\n=== UNIQUE VIOLATIONS ===');
console.log(JSON.stringify(results.uniqueViolations, null, 2));
// Save final results
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(results, null, 2));
console.log(`\nResults saved to: ${OUTPUT_FILE}`);
})();
2.2: Read Scan Results
After running the scan, read the results file:
cat /tmp/a11y-work/scan-results.json
The results include:
- pageInfo: Page structure, images, headings, media
- violations: All violations from all tools (deduplicated)
- byTool: Success/failure status per tool
- toolsSucceeded: Number of tools that completed (1-3)
2.3: Graceful Degradation Decision Tree
| Tools Succeeded | Action |
|---|---|
| 3/3 | ✅ Full coverage - proceed with all results |
| 2/3 | ⚠️ Good coverage - note which tool failed in report |
| 1/3 | ⚠️ Basic coverage - proceed but flag limited confidence |
| 0/3 | ❌ Retry with Vibium MCP, or document failure |
2.4: MANDATORY - Check for Videos and Trigger Pipeline
After reading scan results, check pageInfo.media.videoUrls:
// Check scan-results.json for videos
const results = JSON.parse(fs.readFileSync('/tmp/a11y-work/scan-results.json'));
if (results.pageInfo && results.pageInfo.media.videoUrls.length > 0) {
console.log('=== VIDEOS DETECTED - TRIGGERING VIDEO PIPELINE ===');
for (const video of results.pageInfo.media.videoUrls) {
console.log(`Video: ${video.src}`);
console.log(` Has captions: ${video.hasCaptions}`);
}
// PROCEED TO STEP 7 IMMEDIATELY
}
IF videos detected AND hasCaptions=false → STEP 7 is MANDATORY before generating reports.
STEP 3: CONTEXT-AWARE REMEDIATION (LLM-POWERED)
THIS IS WHERE CLAUDE'S INTELLIGENCE MATTERS.
Generic tools output: aria-label="[DESCRIPTION]"
You output: aria-label="Add to shopping cart" because you understand context.
3.1: Context Analysis (Use Your Reasoning)
For EACH violation, Claude must:
-
READ THE HTML CONTEXT - Don't just see
<button class="btn">, see:<div class="product-card" data-product="Adidas Superstar"> <img src="superstar.jpg" alt="White sneakers"> <span class="price">$99</span> <button class="btn add-to-cart"> <!-- THIS IS THE VIOLATION --> <svg class="icon-cart">...</svg> </button> </div> -
INFER PURPOSE from:
- Class names:
add-to-cart,wishlist,menu-toggle - Parent context: Inside
.product-cardwith product data - Icon classes:
icon-cart,icon-heart,icon-search - Nearby text: Product name, price, "Add to bag"
- Page section: Header nav vs product grid vs checkout
- Class names:
-
GENERATE SPECIFIC FIX:
<!-- NOT THIS (generic template) --> <button aria-label="[DESCRIPTION]"> <!-- THIS (context-aware) --> <button aria-label="Add Adidas Superstar to cart - $99">
3.2: Confidence Scoring
Rate your confidence in each fix:
- 0.9+: Clear context (class="add-to-cart" near product name)
- 0.7-0.9: Reasonable inference (icon-cart class alone)
- <0.7: Needs human review (ambiguous context)
Include confidence in remediation.md:
### Button: `.product-card .btn` (Confidence: 0.95)
**Context:** Inside product card for "Adidas Superstar", has cart icon
**Fix:** `aria-label="Add Adidas Superstar to cart"`
3.2: Remediation Templates by Violation Type
Form Labels (WCAG 1.3.1, 3.3.2, 4.1.2)
<!-- Context: Input inside payment form, near "Card Number" text -->
<!-- Confidence: 0.95 -->
<!-- BEFORE -->
<input type="text" name="cardNumber" placeholder="1234 5678 9012 3456">
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 475
- Forks
- 91
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
qe-a11y-ally- Source
- github.com/proffesor-for-testing/agentic-qe