play.wenpai.net/scripts/generate-report.js
elementary-qa fad50aa160 新增 PHPCS 静态分析作为第 10 个验收维度
- phpcs-scan.sh: WordPress-Extra 标准扫描,提取安全发现输出 JSON
- 报告生成器集成 PHPCS 维度(高危: SQL注入/CSRF, 中危: 输出未转义)
- acceptance-criteria.json 新增 phpcs 阈值,整体门槛调整为 8/10
- Justfile 新增 phpcs-scan 独立任务,test-plugin 流程自动集成
- plugin-check.sh: PCP 自动化脚本(Playground 集成,待完善)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-20 01:20:59 +08:00

311 lines
16 KiB
JavaScript

// 验收测试汇总报告生成器
// 用法: node scripts/generate-report.js <plugin-name> <results-dir>
const fs = require('fs');
const path = require('path');
const pluginName = process.argv[2] || 'unknown';
const resultsDir = process.argv[3] || '.';
const date = new Date().toISOString().slice(0, 10);
const time = new Date().toISOString().slice(11, 19);
// === 加载验收基线配置 ===
const criteriaPath = path.join(process.env.HOME, 'acceptance-criteria.json');
const criteria = readJson(criteriaPath) || {};
// 判定函数: 值越高越好 (Lighthouse, 覆盖率)
function judgeHigher(value, threshold) {
if (!threshold || value == null) return 'skip';
if (value >= threshold.pass) return 'pass';
if (value >= threshold.warn) return 'warn';
return 'fail';
}
// 判定函数: 值越低越好 (漏洞数, 错误数, 溢出数)
function judgeLower(value, threshold) {
if (!threshold || value == null) return 'skip';
if (value <= threshold.pass) return 'pass';
if (value <= threshold.warn) return 'warn';
return 'fail';
}
// 判定结果标记
function badge(result) {
return result === 'pass' ? '✅' : result === 'warn' ? '⚠️' : result === 'fail' ? '❌' : '⏭️';
}
function readJson(filepath) {
try { return JSON.parse(fs.readFileSync(filepath, 'utf8')); }
catch { return null; }
}
function fileExists(filepath) {
try { return fs.statSync(filepath).isFile(); }
catch { return false; }
}
function countFiles(dir, ext) {
try {
return fs.readdirSync(dir).filter(f => f.endsWith(ext)).length;
} catch { return 0; }
}
// === 加载裸 WordPress 基线 ===
const baselinePath = path.join(process.env.HOME, 'baselines/bare-wp/baseline.json');
const baseline = readJson(baselinePath);
function a11yDelta(testIssues, baselineIssues) {
if (!testIssues || !baselineIssues) return testIssues || [];
const baseSet = new Set(baselineIssues.map(i => `${i.code}||${i.selector}`));
return testIssues.filter(i => !baseSet.has(`${i.code}||${i.selector}`));
}
function htmlValidateDelta(testText, baselineRules) {
if (!testText || !baselineRules) return { total: 0, delta: 0, byRule: {} };
const testRules = {};
for (const line of testText.split('\n')) {
const m = line.match(/error\s+(.+?)\s{2,}(\S+)$/);
if (m) testRules[m[2]] = (testRules[m[2]] || 0) + 1;
}
let total = 0, delta = 0;
const byRule = {};
for (const [rule, count] of Object.entries(testRules)) {
total += count;
const baseCount = baselineRules[rule] || 0;
const diff = Math.max(0, count - baseCount);
if (diff > 0) { delta += diff; byRule[rule] = diff; }
}
return { total, delta, byRule };
}
// === 收集各模块结果 ===
const sections = [];
let passCount = 0;
let warnCount = 0;
let failCount = 0;
const dimensions = []; // verdict.json 结构化数据
const issues = []; // 所有发现的问题
// 1. 无障碍(基线增量)
const pa11yHtmlcs = readJson(path.join(resultsDir, 'a11y/pa11y-htmlcs.json'));
const pa11yAxe = readJson(path.join(resultsDir, 'a11y/pa11y-axe.json'));
const htmlcsDelta = baseline ? a11yDelta(pa11yHtmlcs, baseline.a11y.htmlcs) : pa11yHtmlcs || [];
const axeDelta = baseline ? a11yDelta(pa11yAxe, baseline.a11y.axe) : pa11yAxe || [];
const pluginA11y = htmlcsDelta.length + axeDelta.length;
const a11yVerdict = judgeLower(pluginA11y, criteria.accessibility?.violations);
if (a11yVerdict === 'pass') passCount++; else if (a11yVerdict === 'fail') failCount++; else warnCount++;
dimensions.push({ name: 'accessibility', verdict: a11yVerdict, metrics: { pluginIssues: pluginA11y, threshold: criteria.accessibility?.violations?.pass ?? null } });
if (pluginA11y > 0) [...htmlcsDelta, ...axeDelta].forEach(i => issues.push({ dimension: 'accessibility', severity: i.type, detail: `${i.code} @ ${i.selector}` }));
let a11yText = `## ${badge(a11yVerdict)} 无障碍 (a11y)\n- 插件新增: ${pluginA11y} 个问题 (阈值: ≤${criteria.accessibility?.violations?.pass ?? '?'})`;
a11yText += `\n- 基线 (WordPress 核心): HTML_CodeSniffer ${pa11yHtmlcs?.length ?? 0} / axe-core ${pa11yAxe?.length ?? 0}`;
if (pluginA11y > 0) {
for (const i of [...htmlcsDelta, ...axeDelta]) a11yText += `\n - [${i.type}] ${i.code} @ ${i.selector}`;
}
sections.push(a11yText);
// 2. Lighthouse
const lhReport = readJson(path.join(resultsDir, 'performance/lighthouse.report.json'));
if (lhReport?.categories) {
const lhCriteria = criteria.lighthouse || {};
const catMap = { performance: 'performance', accessibility: 'accessibility', 'best-practices': 'bestPractices', seo: 'seo' };
let lhLines = [];
let lhWorst = 'pass';
for (const [key, v] of Object.entries(lhReport.categories)) {
const score = Math.floor(v.score * 100);
const cKey = catMap[key] || key;
const verdict = judgeHigher(score, lhCriteria[cKey]);
if (verdict === 'fail') lhWorst = 'fail';
else if (verdict === 'warn' && lhWorst !== 'fail') lhWorst = 'warn';
const threshold = lhCriteria[cKey] ? ` (阈值: ≥${lhCriteria[cKey].pass})` : '';
lhLines.push(`- ${badge(verdict)} ${v.title}: ${score}${threshold}`);
}
if (lhWorst === 'pass') passCount++; else if (lhWorst === 'fail') failCount++; else warnCount++;
const lhScores = {};
for (const [key, v] of Object.entries(lhReport.categories)) lhScores[key] = Math.floor(v.score * 100);
dimensions.push({ name: 'lighthouse', verdict: lhWorst, metrics: lhScores });
sections.push(`## ${badge(lhWorst)} 性能 (Lighthouse)\n${lhLines.join('\n')}`);
} else {
sections.push('## 性能 (Lighthouse)\n- 未运行或无结果');
}
// 3. 链接检查
const linkCsv = path.join(resultsDir, 'link-check.csv');
if (fileExists(linkCsv)) {
const content = fs.readFileSync(linkCsv, 'utf8');
const broken = (content.match(/^[^#].*error/gm) || []).length;
const linkVerdict = judgeLower(broken, criteria.links?.broken);
if (linkVerdict === 'pass') passCount++; else if (linkVerdict === 'fail') failCount++; else warnCount++;
dimensions.push({ name: 'links', verdict: linkVerdict, metrics: { broken } });
sections.push(`## ${badge(linkVerdict)} 链接检查\n- 断链: ${broken} 个 (阈值: ≤${criteria.links?.broken?.pass ?? '?'})`);
} else {
sections.push('## 链接检查\n- 未运行');
}
// 4. API 扫描
const apiRoutes = readJson(path.join(resultsDir, 'api/routes.json'));
const routeCount = apiRoutes?.routes ? Object.keys(apiRoutes.routes).length : 0;
if (routeCount > 0) passCount++;
dimensions.push({ name: 'api', verdict: routeCount > 0 ? 'pass' : 'skip', metrics: { routes: routeCount } });
sections.push(`## REST API\n- 路由数: ${routeCount || '未运行'}`);
// 5. HTML 验证(基线增量)
const htmlValidate = path.join(resultsDir, 'html-validate.txt');
if (fileExists(htmlValidate)) {
const content = fs.readFileSync(htmlValidate, 'utf8');
const hv = baseline ? htmlValidateDelta(content, baseline.htmlValidateRules) : null;
if (hv) {
const hvVerdict = judgeLower(hv.delta, criteria.html?.errors);
if (hvVerdict === 'pass') passCount++; else if (hvVerdict === 'fail') failCount++; else warnCount++;
dimensions.push({ name: 'html', verdict: hvVerdict, metrics: { delta: hv.delta, total: hv.total } });
let hvText = `## ${badge(hvVerdict)} HTML 验证\n- 插件新增: ${hv.delta} 个问题 (阈值: ≤${criteria.html?.errors?.pass ?? '?'})\n- 基线 (WordPress 核心): ${hv.total}`;
if (hv.delta > 0) {
for (const [rule, count] of Object.entries(hv.byRule)) hvText += `\n - ${rule}: +${count}`;
}
sections.push(hvText);
} else {
const errors = (content.match(/error/gi) || []).length;
const hvVerdict2 = judgeLower(errors, criteria.html?.errors);
if (hvVerdict2 === 'pass') passCount++; else if (hvVerdict2 === 'fail') failCount++; else warnCount++;
dimensions.push({ name: 'html', verdict: hvVerdict2, metrics: { errors } });
sections.push(`## ${badge(hvVerdict2)} HTML 验证\n- 错误: ${errors}`);
}
} else {
sections.push('## HTML 验证\n- 未运行');
}
// 6. 安全扫描
const secReport = readJson(path.join(resultsDir, 'security/security-report.json'))
|| readJson(path.join(process.env.HOME, `test-results/${date}/security-scan/security/security-report.json`));
if (secReport) {
const secCriteria = criteria.security || {};
const highCount = secReport.xssIssues + secReport.sqliIssues;
const medCount = secReport.csrfMissing;
// 过滤 WordPress 核心已知信息泄露
const knownLeaks = secCriteria.knownWpCoreLeaks || [];
const allLeaks = secReport.details?.infoLeak || [];
const pluginLeaks = allLeaks.filter(l => l.exposed && !knownLeaks.some(k => l.name.includes(k)));
const coreLeaks = allLeaks.filter(l => l.exposed && knownLeaks.some(k => l.name.includes(k)));
const lowCount = pluginLeaks.length;
const highV = judgeLower(highCount, secCriteria.high);
const medV = judgeLower(medCount, secCriteria.medium);
const lowV = judgeLower(lowCount, secCriteria.low);
const secWorst = [highV, medV, lowV].includes('fail') ? 'fail' : [highV, medV, lowV].includes('warn') ? 'warn' : 'pass';
if (secWorst === 'pass') passCount++; else if (secWorst === 'fail') failCount++; else warnCount++;
dimensions.push({ name: 'security', verdict: secWorst, metrics: { high: highCount, medium: medCount, low: lowCount } });
if (highCount > 0) issues.push({ dimension: 'security', severity: 'high', detail: `XSS: ${secReport.xssIssues}, SQLi: ${secReport.sqliIssues}` });
if (lowCount > 0) issues.push({ dimension: 'security', severity: 'low', detail: `信息泄露(插件): ${lowCount}` });
let secText = `## ${badge(secWorst)} 安全扫描\n- ${badge(highV)} XSS 反射: ${secReport.xssIssues} (阈值: ≤${secCriteria.high?.pass ?? '?'})\n- ${badge(medV)} CSRF 缺失: ${secReport.csrfMissing}\n- ${badge(highV)} SQLi 泄露: ${secReport.sqliIssues}\n- ${badge(lowV)} 信息泄露(插件): ${lowCount} (阈值: ≤${secCriteria.low?.pass ?? '?'})`;
if (coreLeaks.length > 0) secText += `\n- ⏭️ 信息泄露(WP核心,已忽略): ${coreLeaks.map(l => l.name).join(', ')}`;
sections.push(secText);
} else {
sections.push('## 安全扫描\n- 未运行');
}
// 7. 截图
const screenshotDir = path.join(resultsDir, 'screenshots');
const screenshotCount = countFiles(screenshotDir, '.png');
sections.push(`## 截图\n- 数量: ${screenshotCount}\n- 目录: screenshots/`);
// 8. 视觉回归
const backstopReport = readJson(path.join(process.env.HOME, 'backstop_data/bitmaps_test',
fs.readdirSync(path.join(process.env.HOME, 'backstop_data/bitmaps_test')).sort().pop() || '', 'report.json'));
if (backstopReport?.tests) {
const passed = backstopReport.tests.filter(t => t.status === 'pass').length;
const failed = backstopReport.tests.filter(t => t.status === 'fail').length;
const total = passed + failed;
const diffPct = total > 0 ? (failed / total * 100) : 0;
const vrVerdict = judgeLower(diffPct, criteria.visualRegression?.diffPercent ? { pass: criteria.visualRegression.diffPercent.pass, warn: criteria.visualRegression.diffPercent.warn } : null) || (failed === 0 ? 'pass' : 'fail');
if (vrVerdict === 'pass') passCount++; else if (vrVerdict === 'fail') failCount++; else warnCount++;
dimensions.push({ name: 'visualRegression', verdict: vrVerdict, metrics: { passed, failed, diffPercent: Math.round(diffPct * 100) / 100 } });
sections.push(`## ${badge(vrVerdict)} 视觉回归 (BackstopJS)\n- 通过: ${passed}\n- 失败: ${failed}`);
} else {
sections.push('## 视觉回归 (BackstopJS)\n- 未运行或无结果');
}
// 9. i18n
const i18nReport = readJson(path.join(resultsDir, 'i18n/i18n-report.json'));
if (i18nReport) {
const i18nCriteria = criteria.i18n || {};
const overflowV = judgeLower(i18nReport.overflow.issues, i18nCriteria.overflow);
const overflowStatus = i18nReport.overflow.issues === 0 ? 'OK' : `${i18nReport.overflow.issues} 个溢出`;
let coverageStatus = '未检查';
let coverageV = 'skip';
if (i18nReport.coverage.status === 'ok') {
coverageStatus = `${i18nReport.coverage.percent}% (${i18nReport.coverage.translated}/${i18nReport.coverage.total})`;
coverageV = judgeHigher(i18nReport.coverage.percent, i18nCriteria.coverage);
} else if (i18nReport.coverage.status === 'no_pot') {
coverageStatus = '无 .pot 文件';
coverageV = i18nCriteria.potFile?.pass ? 'fail' : 'warn';
} else {
coverageStatus = i18nReport.coverage.status;
}
const dateStatus = i18nReport.date_format?.found ? 'OK' : '未检测到日期';
// i18n 整体判定: 取溢出和覆盖率中较差的
const i18nWorst = [overflowV, coverageV].includes('fail') ? 'fail' : [overflowV, coverageV].includes('warn') ? 'warn' : 'pass';
if (i18nWorst === 'pass') passCount++; else if (i18nWorst === 'fail') failCount++; else warnCount++;
dimensions.push({ name: 'i18n', verdict: i18nWorst, metrics: { overflow: i18nReport.overflow.issues, coverage: i18nReport.coverage.percent ?? null, hasPot: i18nReport.coverage.status !== 'no_pot' } });
if (i18nReport.coverage.status === 'no_pot') issues.push({ dimension: 'i18n', severity: 'medium', detail: 'zip 包缺少 .pot 文件' });
sections.push(`## ${badge(i18nWorst)} i18n 多语言\n- ${badge(overflowV)} 溢出检测: ${overflowStatus} (阈值: ≤${i18nCriteria.overflow?.pass ?? '?'})\n- ${badge(coverageV)} 翻译覆盖率: ${coverageStatus} (阈值: ≥${i18nCriteria.coverage?.pass ?? '?'}%)\n- 日期格式: ${dateStatus}\n- 截图: zh_CN=${i18nReport.screenshots.zh_CN} en_US=${i18nReport.screenshots.en_US}`);
} else {
sections.push('## i18n 多语言\n- 未运行');
}
// 10. PHPCS 静态分析
const phpcsReport = readJson(path.join(resultsDir, 'phpcs/phpcs-security.json'));
if (phpcsReport) {
const pCriteria = criteria.phpcs || {};
const highFindings = phpcsReport.findings.filter(f => f.severity === 'high').length;
const medFindings = phpcsReport.findings.filter(f => f.severity === 'medium').length;
const highV = judgeLower(highFindings, pCriteria.high);
const medV = judgeLower(medFindings, pCriteria.medium);
const phpcsWorst = [highV, medV].includes('fail') ? 'fail' : [highV, medV].includes('warn') ? 'warn' : 'pass';
if (phpcsWorst === 'pass') passCount++; else if (phpcsWorst === 'fail') failCount++; else warnCount++;
dimensions.push({ name: 'phpcs', verdict: phpcsWorst, metrics: { securityFindings: phpcsReport.securityFindings, high: highFindings, medium: medFindings, totalErrors: phpcsReport.summary.errors } });
if (highFindings > 0) issues.push({ dimension: 'phpcs', severity: 'high', detail: `SQL 未 prepare: ${phpcsReport.byRule['WordPress.DB.PreparedSQL.NotPrepared'] || 0}, Nonce 缺失: ${phpcsReport.byRule['WordPress.Security.NonceVerification.Missing'] || 0}` });
let phpcsText = `## ${badge(phpcsWorst)} 静态分析 (PHPCS)\n- ${badge(highV)} 高危: ${highFindings} (SQL注入/CSRF)\n- ${badge(medV)} 中危: ${medFindings} (输出未转义)`;
for (const [rule, count] of Object.entries(phpcsReport.byRule)) phpcsText += `\n - ${rule}: ${count}`;
sections.push(phpcsText);
} else {
sections.push('## 静态分析 (PHPCS)\n- 未运行');
}
// === 生成报告 ===
const total = passCount + warnCount + failCount;
const passThreshold = criteria.overall?.passThreshold ?? 7;
const verdict = failCount > 0 ? 'FAIL' : passCount >= passThreshold ? 'PASS' : 'WARN';
const report = `# ${pluginName} 验收测试报告
- 日期: ${date} ${time}
- 站点: ${process.env.WP_SITE || 'http://localhost:9400'}
- 结果: **${verdict}** (${passCount} 通过 / ${warnCount} 警告 / ${failCount} 失败)
- 通过门槛: ≥${passThreshold}/${total} 维度通过
- 验收基线: acceptance-criteria.json (${criteria._updated || 'unknown'})
${sections.join('\n\n')}
---
*由 wp-plugin-acceptance-test 自动生成 | 验收基线 ${criteria._updated || 'N/A'}*
`;
const reportPath = path.join(resultsDir, 'report.md');
fs.mkdirSync(resultsDir, { recursive: true });
fs.writeFileSync(reportPath, report);
// === 生成 verdict.json ===
const verdictData = {
plugin: pluginName,
date: `${date}T${time}`,
site: process.env.WP_SITE || 'http://localhost:9400',
verdict,
summary: { pass: passCount, warn: warnCount, fail: failCount, total },
passThreshold,
dimensions,
issues,
criteria: criteria._updated || null
};
const verdictPath = path.join(resultsDir, 'verdict.json');
fs.writeFileSync(verdictPath, JSON.stringify(verdictData, null, 2));
console.log(`[report] ${verdict}${passCount}/${total} 通过, ${warnCount} 警告, ${failCount} 失败`);
console.log(`[report] 报告: ${reportPath}`);
console.log(`[verdict] ${verdictPath}`);