// built-in skill: accessibility audit import { launchReapedChrome } from '../../../../../scripts/lib/reap-browser' import type { RNXSkill } from '../types' export const skill: RNXSkill = { name: 'a11y-review', description: 'Check the app for accessibility issues', type: 'review', version: '1.0.0', triggers: ['accessibility', 'a11y', 'check accessibility', 'audit accessibility'], tools: [ { name: 'audit_accessibility', description: 'Run an accessibility audit on the current screen', parameters: { screen: { type: 'string', description: 'Screen name or URL to audit' }, }, async execute(params, context) { const { chromium } = await import('playwright') const path = await import('path') const fs = await import('fs') // sootsim renders CanvasKit — require GPU-backed Chrome (Metal). the // bundled swiftshader shell software-renders the canvas and pegs every // core; never use it for sootsim. const browser = await launchReapedChrome((options) => chromium.launch(options), { headless: true, }) const page = await browser.newPage({ viewport: { width: 500, height: 900 } }) try { await page.goto(context.url, { waitUntil: 'networkidle' }) await page.waitForFunction('window.__sootsimTest', { timeout: 10000 }) await page.waitForTimeout(2000) // get accessibility tree const tree = await page.evaluate( 'window.__sootsimTest.dumpAccessibilityTree(10)', ) const allNodes = await page.evaluate('window.__sootsimTest.queryAll({})') // analyze for common issues const issues: { severity: 'error' | 'warning' | 'info' message: string node?: string }[] = [] if (Array.isArray(allNodes)) { for (const node of allNodes) { // check for missing accessibility labels on interactive elements if ( (node.accessibilityRole === 'button' || node.accessibilityRole === 'link' || node.type === 'TouchableOpacity' || node.type === 'Pressable') && !node.accessibilityLabel && !node.text ) { issues.push({ severity: 'error', message: `Interactive element missing accessibility label`, node: node.testID || node.id || node.type, }) } // check for missing roles if ( (node.type === 'TouchableOpacity' || node.type === 'Pressable') && !node.accessibilityRole ) { issues.push({ severity: 'warning', message: `Touchable element missing accessibility role`, node: node.testID || node.id || node.type, }) } // check for images without alt text if (node.type === 'Image' && !node.accessibilityLabel && !node.accessible) { issues.push({ severity: 'warning', message: `Image missing accessibility label`, node: node.testID || node.id || 'Image', }) } // check for small touch targets if ( node.layout && (node.accessibilityRole === 'button' || node.type === 'TouchableOpacity') && (node.layout.width < 44 || node.layout.height < 44) ) { issues.push({ severity: 'warning', message: `Touch target too small (${Math.round(node.layout.width)}x${Math.round(node.layout.height)}pt, minimum 44x44pt)`, node: node.testID || node.text || node.type, }) } } } // write report const report = { timestamp: new Date().toISOString(), screen: params.screen || 'current', nodeCount: Array.isArray(allNodes) ? allNodes.length : 0, issues, errors: issues.filter((i) => i.severity === 'error').length, warnings: issues.filter((i) => i.severity === 'warning').length, tree: typeof tree === 'string' ? tree : JSON.stringify(tree), } const reportPath = path.join(context.outputDir, 'a11y-report.json') fs.mkdirSync(path.dirname(reportPath), { recursive: true }) fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)) return { success: issues.filter((i) => i.severity === 'error').length === 0, message: `${report.errors} errors, ${report.warnings} warnings across ${report.nodeCount} nodes`, artifacts: [{ type: 'report', name: 'a11y-report', path: reportPath }], data: report, } } finally { await browser.close() } }, }, ], }