- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注 skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets 等复用桩。 - 集成层:config/test/jest.integration.config.js + tests-integration/ 真库 一次性 SQLite 用例,teardown 守卫开发库指纹。 - 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、 check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse 配置收敛。
170 lines
5.7 KiB
JavaScript
170 lines
5.7 KiB
JavaScript
#!/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: '联系我们' },
|
|
];
|
|
|
|
// 负 PID = 杀整个进程组:`npm run preview` 只是包装进程,真正 LISTEN :3000 的是 next-server 孙进程;
|
|
// 只 kill 包装进程会让孙进程被 init 收养继续占端口,下一轮 E2E / Lighthouse 会静默复用它。
|
|
let serverStopped = false;
|
|
function stopServer(server) {
|
|
if (!server || !server.pid || serverStopped) return;
|
|
serverStopped = true;
|
|
try {
|
|
process.kill(-server.pid, 'SIGTERM');
|
|
} catch {
|
|
/* 进程组已自行退出 */
|
|
}
|
|
}
|
|
|
|
function startServer() {
|
|
return new Promise((resolve, reject) => {
|
|
const server = spawn('npm', ['run', 'preview'], {
|
|
cwd: path.resolve(__dirname, '..'),
|
|
stdio: 'pipe',
|
|
shell: true,
|
|
detached: 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 {
|
|
// 服务未起:连接被拒
|
|
}
|
|
// 就绪等待必须每轮都退避:启动期返回 503/500 等非 ok 状态时,
|
|
// 若只在 catch 里 sleep,30 次重试会在毫秒内跑完并误报「服务未就绪」。
|
|
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() 不会执行 finally,所以两处出口都得自己收服服务再退出。
|
|
stopServer(server);
|
|
process.exit(totalViolations > 0 ? 1 : 0);
|
|
} catch (error) {
|
|
console.error('审计执行失败:', error);
|
|
stopServer(server);
|
|
process.exit(1);
|
|
} finally {
|
|
stopServer(server);
|
|
}
|
|
}
|
|
|
|
runAudit();
|