/** * Soak 汇总报告生成器 —— 把一整轮夜间 soak 的所有结果聚合成**一份**报告(Markdown)。 * 含:概览、各设备成功率、失败模式分析、失败时间线(死区)、结论、存在的问题、解决方案。 * * 用法: * npx ts-node scripts/soak-report.ts [soak日志时间戳, 如 20260602-191820] # 缺省取最新 * 输出:reports/soak-report-.md * (overnight-soak.sh 结束时会自动调用本脚本。) */ import * as fs from 'fs'; import * as path from 'path'; const reportsDir = path.resolve(__dirname, '../reports'); // 失败模式 → 说明 + 解决方案(知识库) const FAILURE_KB: Record = { 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; } 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 = {}; // 每轮结果(用于死区检测):cycle -> {dev: pass/fail} const perCycle: Record> = {}; 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 = {}; 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(); 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, '>'); } 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 += `${esc(dev)}${s.pass}/${s.total}` + `${(raw * 100).toFixed(1)}%` + `${(hh * 100).toFixed(1)}% (${h.pass}/${h.total})`; } // 失败模式表 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 += `${esc(dev)}(无失败明细)-`; continue; } for (const k of keys) modeRows += `${esc(dev)}${esc(FAILURE_KB[k]?.label || k)}${modes[k]}`; } // 结论/问题/解决方案 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(`本轮存在 ${deadCycles.length} 个整轮死区,是整体成功率偏低的主因;按"剔除死区后"看设备真实可靠性更准。`); if (stableDevs.length) concl.push(`稳定设备(剔除死区后 ≥95%):${stableDevs.map(esc).join('、')}。`); if (weak.length) concl.push(`偏脆设备(剔除死区后 <85%):${weak.map(esc).join('、')} —— 需针对性优化其配对/连接。`); if (!weak.length && !deadCycles.length) concl.push('各设备成功率均良好,添加流程稳定。'); const hitModes = new Set(); for (const dev of devOrder) Object.keys(stats[dev].modes).forEach((k) => hitModes.add(k)); const probs: string[] = []; if (deadCycles.length) probs.push(`整轮死区:连续 ${deadCycles.length} 轮全失败,单点卡死引发级联(无人值守缺重启级恢复时尤甚)。`); for (const k of hitModes) { if (k === 'navFail' && deadCycles.length) continue; probs.push(`${esc(FAILURE_KB[k]?.label || k)}`); } 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 ? `

检测到 ${deadCycles.length} 个"整轮全失败"轮次:Cycle ${deadCycles[0]} ~ ${deadCycles[deadCycles.length - 1]}(全设备同时失败,通常 App 卡死未恢复 → 级联)。

` : `

未检测到连续整轮死区。

`; return ` Soak 报告 ${ts}

夜间添加稳定性 Soak 报告

运行:${esc(startT)} → ${esc(endT)} | 轮次:${cycles} | 日志:soak-${ts}

一、各设备成功率

${rateRows}
设备通过/总数成功率剔除死区后

"剔除死区后"= 排除整轮全失败的卡死轮次后,设备本身的真实可靠性。

二、失败模式分布

${modeRows}
设备失败模式次数

三、失败时间线(死区检测)

${deadNote}

四、结论

    ${concl.map((c) => `
  1. ${c}
  2. `).join('')}

五、存在的问题

    ${probs.map((c) => `
  1. ${c}
  2. `).join('')}

六、解决方案

    ${sols.map((c) => `
  1. ${c}
  2. `).join('')}
`; } // 兼容:healthy 以设备名为 key main();