270 lines
15 KiB
XML
270 lines
15 KiB
XML
/**
|
||
* Soak 汇总报告生成器 —— 把一整轮夜间 soak 的所有结果聚合成**一份**报告(Markdown)。
|
||
* 含:概览、各设备成功率、失败模式分析、失败时间线(死区)、结论、存在的问题、解决方案。
|
||
*
|
||
* 用法:
|
||
* npx ts-node scripts/soak-report.ts [soak日志时间戳, 如 20260602-191820] # 缺省取最新
|
||
* 输出:reports/soak-report-<TS>.md
|
||
* (overnight-soak.sh 结束时会自动调用本脚本。)
|
||
*/
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
|
||
const reportsDir = path.resolve(__dirname, '../reports');
|
||
|
||
// 失败模式 → 说明 + 解决方案(知识库)
|
||
const FAILURE_KB: Record<string, { label: string; solution: string }> = {
|
||
navFail: {
|
||
label: '导航到添加页失败(找不到首页"+")',
|
||
solution: '残留弹框/卡死页挡住"+"。已修:navigateToAddPage 找不到"+"时 force-stop 重启 App 兜底(最多3次);dismissPopupIfPresent 识别"连接失败"弹框;removeAllDevices 开头重启清场。',
|
||
},
|
||
pairNotConnected: {
|
||
label: '按了继电器但未连上(配对未触发)',
|
||
solution: '物理配对触发不稳(如 remote 凸+凹同时按 3s,~29% 漏)。已修:addDeviceWithSerialPairing 配对未发现设备就重按重试(pairRetries=3,判据=连接关键字或设备名命中)。',
|
||
},
|
||
connTimeout: { label: '连接超时', solution: '检查蓝牙/设备环境;必要时延长 waitForConnection 或重按重试。' },
|
||
noNext: { label: '引导页未找到 Next', solution: '核对引导页文案/时序;tapNext 增加等待或重试。' },
|
||
catFail: { label: '品类选择失败', solution: '已用 UiScrollable.scrollIntoView 直达深层品类 + 重启兜底。' },
|
||
calibFail: { label: '行程校验异常(Something went wrong)', solution: 'curtain3 校准需设备装真实导轨;Auto-Calibrate Close 后重试点 Finish 已加。' },
|
||
other: { label: '其它/未分类失败', solution: '查 detail 日志对应 Cycle 段落定位。' },
|
||
};
|
||
|
||
interface DevStat { pass: number; total: number; modes: Record<string, number>; }
|
||
|
||
function classifySegment(seg: string): string {
|
||
if (/cannot navigate to Add Device|addBto not found/.test(seg)) return 'navFail';
|
||
if (/cannot select category|no .* option/.test(seg)) return 'catFail';
|
||
if (/connection timeout/.test(seg)) return 'connTimeout';
|
||
if (/引导页未找到 Next|未找到 Next/.test(seg)) return 'noNext';
|
||
if (/Something went wrong/.test(seg)) return 'calibFail';
|
||
// 跑了继电器但最终失败且没连上
|
||
if (/relay: .*pairing/.test(seg) && /✗|expected false to be true/.test(seg)) return 'pairNotConnected';
|
||
return 'other';
|
||
}
|
||
|
||
function main() {
|
||
const ts = process.argv[2] || latestTs();
|
||
if (!ts) { console.error('未找到 soak 日志'); process.exit(1); }
|
||
const mainLog = path.join(reportsDir, `soak-${ts}.log`);
|
||
const detailLog = path.join(reportsDir, `soak-${ts}.detail.log`);
|
||
if (!fs.existsSync(mainLog)) { console.error('找不到', mainLog); process.exit(1); }
|
||
|
||
const mainText = fs.readFileSync(mainLog, 'utf-8');
|
||
const detailText = fs.existsSync(detailLog) ? fs.readFileSync(detailLog, 'utf-8') : '';
|
||
|
||
// 1) 主日志:时间范围、轮次、每设备 PASS/FAIL 序列
|
||
const lines = mainText.split('\n');
|
||
const stamps = lines.map((l) => (l.match(/^\[(\d\d-\d\d \d\d:\d\d:\d\d)\]/) || [])[1]).filter(Boolean) as string[];
|
||
const startT = stamps[0] || '?', endT = stamps[stamps.length - 1] || '?';
|
||
const cycles = (mainText.match(/----- Cycle \d+ -----/g) || []).length;
|
||
const devOrder: string[] = [];
|
||
const stats: Record<string, DevStat> = {};
|
||
// 每轮结果(用于死区检测):cycle -> {dev: pass/fail}
|
||
const perCycle: Record<number, Record<string, boolean>> = {};
|
||
let curCycle = 0;
|
||
for (const l of lines) {
|
||
const cm = l.match(/----- Cycle (\d+) -----/);
|
||
if (cm) { curCycle = +cm[1]; perCycle[curCycle] = {}; continue; }
|
||
const rm = l.match(/^\[.*\] (\w+) (PASS|FAIL)/);
|
||
if (rm) {
|
||
const [, dev, res] = rm;
|
||
if (!stats[dev]) { stats[dev] = { pass: 0, total: 0, modes: {} }; devOrder.push(dev); }
|
||
stats[dev].total++; if (res === 'PASS') stats[dev].pass++;
|
||
if (perCycle[curCycle]) perCycle[curCycle][dev] = res === 'PASS';
|
||
}
|
||
}
|
||
|
||
// 2) detail 日志:按 Cycle/device 段落归类失败模式
|
||
if (detailText) {
|
||
const segRe = />>> Cycle \d+\s+(\w+)/g;
|
||
const idxs: { dev: string; pos: number }[] = [];
|
||
let m;
|
||
while ((m = segRe.exec(detailText)) !== null) idxs.push({ dev: m[1], pos: m.index });
|
||
for (let i = 0; i < idxs.length; i++) {
|
||
const seg = detailText.slice(idxs[i].pos, i + 1 < idxs.length ? idxs[i + 1].pos : undefined);
|
||
if (/✗|FAIL tests\/|expected false to be true/.test(seg) && !/✓ \[PASS\]/.test(seg.slice(0, 400))) {
|
||
const dev = idxs[i].dev;
|
||
if (!stats[dev]) { stats[dev] = { pass: 0, total: 0, modes: {} }; devOrder.push(dev); }
|
||
const mode = classifySegment(seg);
|
||
stats[dev].modes[mode] = (stats[dev].modes[mode] || 0) + 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3) 死区:连续"全设备 FAIL"的轮次
|
||
const deadCycles: number[] = [];
|
||
for (const c of Object.keys(perCycle).map(Number).sort((a, b) => a - b)) {
|
||
const r = perCycle[c]; const vals = Object.values(r);
|
||
if (vals.length && vals.every((v) => v === false)) deadCycles.push(c);
|
||
}
|
||
|
||
// 4) 生成报告
|
||
// 剔除死区后的"健康期"成功率(更反映设备本身可靠性)
|
||
const deadSet = new Set(deadCycles);
|
||
const healthy: Record<string, { pass: number; total: number }> = {};
|
||
for (const dev of devOrder) healthy[dev] = { pass: 0, total: 0 };
|
||
for (const c of Object.keys(perCycle).map(Number)) {
|
||
if (deadSet.has(c)) continue;
|
||
for (const dev of Object.keys(perCycle[c])) {
|
||
if (!healthy[dev]) healthy[dev] = { pass: 0, total: 0 };
|
||
healthy[dev].total++;
|
||
if (perCycle[c][dev]) healthy[dev].pass++;
|
||
}
|
||
}
|
||
const report = render({ ts, startT, endT, cycles, devOrder, stats, healthy, deadCycles, perCycleCount: Object.keys(perCycle).length });
|
||
const out = path.join(reportsDir, `soak-report-${ts}.md`);
|
||
fs.writeFileSync(out, report, 'utf-8');
|
||
|
||
const html = renderHtml({ ts, startT, endT, cycles, devOrder, stats, healthy, deadCycles });
|
||
const outHtml = path.join(reportsDir, `soak-report-${ts}.html`);
|
||
fs.writeFileSync(outHtml, html, 'utf-8');
|
||
|
||
console.log('已生成汇总报告:');
|
||
console.log(' Markdown:', out);
|
||
console.log(' HTML :', outHtml);
|
||
console.log('\n' + report);
|
||
}
|
||
|
||
function pct(p: number, t: number) { return t ? ((p / t) * 100).toFixed(1) + '%' : '-'; }
|
||
|
||
function render(d: any): string {
|
||
const { ts, startT, endT, cycles, devOrder, stats, healthy, deadCycles } = d;
|
||
let r = `# 夜间添加稳定性 Soak 报告\n\n`;
|
||
r += `- 运行:${startT} → ${endT} | 轮次:${cycles} | 日志:soak-${ts}\n\n`;
|
||
|
||
r += `## 一、各设备成功率\n\n| 设备 | 通过/总数 | 成功率 | 剔除死区后 |\n|---|---|---|---|\n`;
|
||
for (const dev of devOrder) {
|
||
const s = stats[dev]; const h = healthy[dev] || { pass: 0, total: 0 };
|
||
r += `| ${dev} | ${s.pass}/${s.total} | ${pct(s.pass, s.total)} | ${pct(h.pass, h.total)} (${h.pass}/${h.total}) |\n`;
|
||
}
|
||
r += `\n> "剔除死区后"= 排除整轮全失败的卡死轮次后,设备本身的真实可靠性。\n`;
|
||
|
||
r += `\n## 二、失败模式分布\n\n| 设备 | 失败模式 | 次数 |\n|---|---|---|\n`;
|
||
for (const dev of devOrder) {
|
||
const modes = stats[dev].modes; const keys = Object.keys(modes).sort((a, b) => modes[b] - modes[a]);
|
||
if (!keys.length) { r += `| ${dev} | (无失败明细) | - |\n`; continue; }
|
||
for (const k of keys) r += `| ${dev} | ${FAILURE_KB[k]?.label || k} | ${modes[k]} |\n`;
|
||
}
|
||
|
||
// 死区
|
||
r += `\n## 三、失败时间线(死区检测)\n\n`;
|
||
if (deadCycles.length) {
|
||
r += `检测到 **${deadCycles.length}** 个"整轮全失败"轮次(全设备同时失败,通常是 App 卡死未恢复):Cycle ${deadCycles[0]} ~ ${deadCycles[deadCycles.length - 1]}。\n`;
|
||
r += `> 这类连续死区会严重拉低整体成功率,且多为单点卡死(如未处理的弹框)引发的级联。\n`;
|
||
} else { r += `未检测到连续整轮死区。\n`; }
|
||
|
||
// 结论
|
||
r += `\n## 四、结论\n\n`;
|
||
const hrate = (dv: string) => { const h = healthy[dv] || { pass: 0, total: 0 }; return h.total ? h.pass / h.total : 0; };
|
||
const stableDevs = devOrder.filter((dv: string) => hrate(dv) >= 0.95);
|
||
const weak = devOrder.filter((dv: string) => (healthy[dv]?.total || 0) > 0 && hrate(dv) < 0.85);
|
||
if (deadCycles.length) r += `- 本轮存在 ${deadCycles.length} 个整轮死区,是整体成功率偏低的**主因**;按"剔除死区后"看设备真实可靠性更准。\n`;
|
||
if (stableDevs.length) r += `- 稳定设备(剔除死区后 ≥95%):${stableDevs.join('、')}。\n`;
|
||
if (weak.length) r += `- 偏脆设备(剔除死区后 <85%):${weak.join('、')} —— 需针对性优化其配对/连接。\n`;
|
||
if (!weak.length && !deadCycles.length) r += `- 各设备成功率均良好,添加流程稳定。\n`;
|
||
|
||
// 问题 + 解决方案(基于实际命中的失败模式)
|
||
const hitModes = new Set<string>();
|
||
for (const dev of devOrder) Object.keys(stats[dev].modes).forEach((k) => hitModes.add(k));
|
||
r += `\n## 五、存在的问题\n\n`;
|
||
if (deadCycles.length) r += `1. **整轮死区**:连续 ${deadCycles.length} 轮全失败,单点卡死引发级联(无人值守缺重启级恢复时尤甚)。\n`;
|
||
let i = deadCycles.length ? 2 : 1;
|
||
for (const k of hitModes) { if (k === 'navFail' && deadCycles.length) continue; r += `${i++}. **${FAILURE_KB[k]?.label || k}**\n`; }
|
||
if (i === 1) r += `(无显著问题)\n`;
|
||
|
||
r += `\n## 六、解决方案\n\n`;
|
||
let j = 1;
|
||
if (deadCycles.length || hitModes.has('navFail')) r += `${j++}. ${FAILURE_KB.navFail.solution}\n`;
|
||
for (const k of hitModes) { if (k === 'navFail') continue; r += `${j++}. ${FAILURE_KB[k]?.solution || ''}\n`; }
|
||
if (j === 1) r += `保持现状,继续监控。\n`;
|
||
|
||
return r;
|
||
}
|
||
|
||
function latestTs(): string | null {
|
||
if (!fs.existsSync(reportsDir)) return null;
|
||
const f = fs.readdirSync(reportsDir).filter((x) => /^soak-\d+-\d+\.log$/.test(x)).sort().pop();
|
||
return f ? (f.match(/soak-(\d+-\d+)\.log/) || [])[1] : null;
|
||
}
|
||
|
||
// ---- HTML 报告 ----
|
||
function rateColor(rate: number): string {
|
||
if (rate >= 0.95) return '#1a9850'; // 绿
|
||
if (rate >= 0.85) return '#f5a623'; // 橙
|
||
return '#d73027'; // 红
|
||
}
|
||
function esc(s: string): string {
|
||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function renderHtml(d: any): string {
|
||
const { ts, startT, endT, cycles, devOrder, stats, healthy, deadCycles } = d;
|
||
const hrate = (dv: string) => { const h = healthy[dv] || { pass: 0, total: 0 }; return h.total ? h.pass / h.total : 0; };
|
||
|
||
// 成功率表
|
||
let rateRows = '';
|
||
for (const dev of devOrder) {
|
||
const s = stats[dev]; const h = healthy[dev] || { pass: 0, total: 0 };
|
||
const raw = s.total ? s.pass / s.total : 0; const hh = h.total ? h.pass / h.total : 0;
|
||
rateRows += `<tr><td>${esc(dev)}</td><td>${s.pass}/${s.total}</td>` +
|
||
`<td><b style="color:${rateColor(raw)}">${(raw * 100).toFixed(1)}%</b></td>` +
|
||
`<td><b style="color:${rateColor(hh)}">${(hh * 100).toFixed(1)}%</b> <span class="dim">(${h.pass}/${h.total})</span></td></tr>`;
|
||
}
|
||
// 失败模式表
|
||
let modeRows = '';
|
||
for (const dev of devOrder) {
|
||
const modes = stats[dev].modes; const keys = Object.keys(modes).sort((a, b) => modes[b] - modes[a]);
|
||
if (!keys.length) { modeRows += `<tr><td>${esc(dev)}</td><td class="dim">(无失败明细)</td><td>-</td></tr>`; continue; }
|
||
for (const k of keys) modeRows += `<tr><td>${esc(dev)}</td><td>${esc(FAILURE_KB[k]?.label || k)}</td><td>${modes[k]}</td></tr>`;
|
||
}
|
||
// 结论/问题/解决方案
|
||
const stableDevs = devOrder.filter((dv: string) => hrate(dv) >= 0.95);
|
||
const weak = devOrder.filter((dv: string) => (healthy[dv]?.total || 0) > 0 && hrate(dv) < 0.85);
|
||
const concl: string[] = [];
|
||
if (deadCycles.length) concl.push(`本轮存在 <b>${deadCycles.length}</b> 个整轮死区,是整体成功率偏低的<b>主因</b>;按"剔除死区后"看设备真实可靠性更准。`);
|
||
if (stableDevs.length) concl.push(`稳定设备(剔除死区后 ≥95%):<b>${stableDevs.map(esc).join('、')}</b>。`);
|
||
if (weak.length) concl.push(`偏脆设备(剔除死区后 <85%):<b style="color:#d73027">${weak.map(esc).join('、')}</b> —— 需针对性优化其配对/连接。`);
|
||
if (!weak.length && !deadCycles.length) concl.push('各设备成功率均良好,添加流程稳定。');
|
||
|
||
const hitModes = new Set<string>();
|
||
for (const dev of devOrder) Object.keys(stats[dev].modes).forEach((k) => hitModes.add(k));
|
||
const probs: string[] = [];
|
||
if (deadCycles.length) probs.push(`<b>整轮死区</b>:连续 ${deadCycles.length} 轮全失败,单点卡死引发级联(无人值守缺重启级恢复时尤甚)。`);
|
||
for (const k of hitModes) { if (k === 'navFail' && deadCycles.length) continue; probs.push(`<b>${esc(FAILURE_KB[k]?.label || k)}</b>`); }
|
||
if (!probs.length) probs.push('无显著问题。');
|
||
|
||
const sols: string[] = [];
|
||
if (deadCycles.length || hitModes.has('navFail')) sols.push(esc(FAILURE_KB.navFail.solution));
|
||
for (const k of hitModes) { if (k === 'navFail') continue; if (FAILURE_KB[k]) sols.push(esc(FAILURE_KB[k].solution)); }
|
||
if (!sols.length) sols.push('保持现状,继续监控。');
|
||
|
||
const deadNote = deadCycles.length
|
||
? `<p class="warn">检测到 <b>${deadCycles.length}</b> 个"整轮全失败"轮次:Cycle ${deadCycles[0]} ~ ${deadCycles[deadCycles.length - 1]}(全设备同时失败,通常 App 卡死未恢复 → 级联)。</p>`
|
||
: `<p>未检测到连续整轮死区。</p>`;
|
||
|
||
return `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
|
||
<title>Soak 报告 ${ts}</title><style>
|
||
body{font-family:-apple-system,"PingFang SC",Segoe UI,sans-serif;margin:32px;color:#222;background:#fafafa;line-height:1.6}
|
||
h1{font-size:22px}h2{font-size:17px;border-left:4px solid #4a90d9;padding-left:10px;margin-top:28px}
|
||
table{border-collapse:collapse;margin:10px 0;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
||
th,td{border:1px solid #e2e2e2;padding:7px 14px;text-align:left;font-size:14px}
|
||
th{background:#f0f4f8}.dim{color:#999;font-size:12px}
|
||
.meta{color:#555;font-size:13px}.warn{background:#fff3cd;border-left:4px solid #f5a623;padding:8px 12px}
|
||
ol{padding-left:22px}ol li{margin:5px 0}
|
||
</style></head><body>
|
||
<h1>夜间添加稳定性 Soak 报告</h1>
|
||
<p class="meta">运行:${esc(startT)} → ${esc(endT)} | 轮次:${cycles} | 日志:soak-${ts}</p>
|
||
<h2>一、各设备成功率</h2>
|
||
<table><tr><th>设备</th><th>通过/总数</th><th>成功率</th><th>剔除死区后</th></tr>${rateRows}</table>
|
||
<p class="dim">"剔除死区后"= 排除整轮全失败的卡死轮次后,设备本身的真实可靠性。</p>
|
||
<h2>二、失败模式分布</h2>
|
||
<table><tr><th>设备</th><th>失败模式</th><th>次数</th></tr>${modeRows}</table>
|
||
<h2>三、失败时间线(死区检测)</h2>${deadNote}
|
||
<h2>四、结论</h2><ol>${concl.map((c) => `<li>${c}</li>`).join('')}</ol>
|
||
<h2>五、存在的问题</h2><ol>${probs.map((c) => `<li>${c}</li>`).join('')}</ol>
|
||
<h2>六、解决方案</h2><ol>${sols.map((c) => `<li>${c}</li>`).join('')}</ol>
|
||
</body></html>`;
|
||
}
|
||
|
||
// 兼容:healthy 以设备名为 key
|
||
main(); |