AI_UIAutomation/scripts/soak-report.ts

270 lines
15 KiB
XML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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();