#!/usr/bin/env node /** * 可访问性自动化审计脚本 * * 使用 @axe-core/playwright 对关键页面进行 WCAG 2.1 AA 规则扫描。 * 脚本会自动启动 preview 服务,扫描完成后关闭服务并输出报告。 */ const { chromium } = require('playwright'); const { AxeBuilder } = require('@axe-core/playwright'); const { spawn } = require('child_process'); const path = require('path'); const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000'; const PAGES = [ { path: '/', name: '首页' }, { path: '/about', name: '关于我们' }, { path: '/products', name: '产品中心' }, { path: '/products/erp', name: '产品详情-ERP' }, { path: '/solutions', name: '解决方案' }, { path: '/solutions/manufacturing', name: '解决方案详情-制造' }, { path: '/services', name: '服务列表' }, { path: '/services/software', name: '服务详情-软件开发' }, { path: '/news', name: '新闻列表' }, { path: '/contact', name: '联系我们' }, ]; function startServer() { return new Promise((resolve, reject) => { const server = spawn('npm', ['run', 'preview'], { cwd: path.resolve(__dirname, '..'), stdio: 'pipe', shell: true, }); server.stdout.on('data', (data) => { const output = data.toString(); if (output.includes('Ready') || output.includes('3000')) { resolve(server); } }); server.stderr.on('data', (data) => { const output = data.toString(); if (output.includes('EADDRINUSE') || output.includes('Failed')) { reject(new Error(output)); } }); setTimeout(() => resolve(server), 5000); }); } async function waitForServer(url, retries = 30) { for (let i = 0; i < retries; i++) { try { const res = await fetch(url, { method: 'HEAD' }); if (res.ok || res.status === 404) return; } catch { await new Promise((r) => setTimeout(r, 1000)); } } throw new Error(`Server at ${url} did not become ready in time`); } async function runAudit() { let server; try { console.log('🚀 启动预览服务...'); server = await startServer(); await waitForServer(BASE_URL); console.log('🔍 启动浏览器并运行可访问性审计...\n'); const browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await context.newPage(); const results = []; let totalViolations = 0; for (const { path: pagePath, name } of PAGES) { const url = `${BASE_URL}${pagePath}`; try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(1500); const analysis = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) .analyze(); const violations = analysis.violations; totalViolations += violations.length; results.push({ name, url, passed: violations.length === 0, violations: violations.map((v) => ({ rule: v.id, impact: v.impact, description: v.description, help: v.help, helpUrl: v.helpUrl, nodes: v.nodes.length, targets: v.nodes.map((n) => ({ target: n.target, html: n.html?.slice(0, 200), })), })), }); const status = violations.length === 0 ? '✅' : '❌'; console.log(`${status} ${name} (${violations.length} 个问题)`); for (const v of violations) { console.log(` • [${v.impact?.toUpperCase() ?? 'UNKNOWN'}] ${v.id}: ${v.help} (${v.nodes.length} 个节点)`); for (const node of v.nodes.slice(0, 3)) { const target = Array.isArray(node.target) ? node.target.join(' > ') : String(node.target); console.log(` - ${target}`); } } } catch (error) { console.error(`❌ ${name} 审计失败:`, error.message); results.push({ name, url, passed: false, error: error.message, violations: [] }); } } await browser.close(); console.log('\n📊 审计摘要'); console.log(` 扫描页面: ${results.length}`); console.log(` 通过页面: ${results.filter((r) => r.passed).length}`); console.log(` 失败页面: ${results.filter((r) => !r.passed).length}`); console.log(` 问题总数: ${totalViolations}`); const reportPath = path.resolve(__dirname, '../accessibility-report.json'); require('fs').writeFileSync(reportPath, JSON.stringify(results, null, 2)); console.log(`\n📝 详细报告已保存: ${reportPath}`); process.exit(totalViolations > 0 ? 1 : 0); } catch (error) { console.error('审计执行失败:', error); process.exit(1); } finally { if (server) { server.kill('SIGTERM'); } } } runAudit();