- 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>
311 lines
16 KiB
JavaScript
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}`);
|