import * as fs from 'fs'; import * as path from 'path'; export interface TestResult { suite: string; name: string; status: 'PASS' | 'FAIL' | 'SKIP'; duration: number; detail: string; screenshot?: string; } export interface ReportMeta { suite: string; platform: string; duration: number; passed: number; failed: number; } const SHARED_RESULTS_FILE = path.resolve(__dirname, '../reports/.results.json'); export class TestReporter { private results: TestResult[] = []; private startTime: number = 0; private suite: string; private platform: string; constructor(suite: string, platform: string) { // 控制阶段 BLE/WiFi 双跑:给 suite 追加 PROTO 后缀,使 HTML 报告(按 suite||name 去重) // 把 ble、wifi 两遍**各算一条**(否则同 suite+同名被合并 → 用例总数偏低);vitest 重试占位仍正确合并。 // 添加阶段不设 PROTO → 无后缀;iOS 控制走 SKIP_PROTO_NET(不设 PROTO)→ 单跑无后缀,均不受影响。 const proto = (process.env.PROTO || '').trim(); this.suite = proto ? `${suite}_${proto}` : suite; this.platform = platform; this.startTime = Date.now(); } record(name: string, status: 'PASS' | 'FAIL' | 'SKIP', duration: number, detail: string, screenshot?: string) { this.results.push({ suite: this.suite, name, status, duration, detail, screenshot }); } generate() { const totalDuration = Number(((Date.now() - this.startTime) / 1000).toFixed(1)); const passed = this.results.filter(r => r.status === 'PASS').length; const failed = this.results.filter(r => r.status === 'FAIL').length; const skipped = this.results.filter(r => r.status === 'SKIP').length; this.printConsole(totalDuration, passed, failed, skipped); this.appendSharedResults(); } private printConsole(totalDuration: number, passed: number, failed: number, skipped: number) { const total = this.results.length; const effective = total - skipped; const passRate = effective > 0 ? ((passed / effective) * 100).toFixed(1) : '0.0'; console.log('\n' + '='.repeat(60)); console.log(` 测试结果报告 - ${this.suite}`); console.log('='.repeat(60)); console.log(` 平台: ${this.platform}`); console.log(` 总耗时: ${totalDuration}s`); console.log(` 结果: ${passed} 通过 / ${failed} 失败 / ${skipped} 跳过 (有效通过率: ${passRate}%)`); console.log('-'.repeat(60)); this.results.forEach(r => { const icon = r.status === 'PASS' ? '✓' : r.status === 'FAIL' ? '✗' : '○'; console.log(` ${icon} [${r.status}] ${r.name} (${(r.duration / 1000).toFixed(1)}s)`); if (r.detail) console.log(` ${r.detail}`); }); console.log('='.repeat(60)); } private appendSharedResults() { const reportDir = path.dirname(SHARED_RESULTS_FILE); if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true }); let existing: { platform: string; results: TestResult[] } = { platform: this.platform, results: [] }; if (fs.existsSync(SHARED_RESULTS_FILE)) { try { existing = JSON.parse(fs.readFileSync(SHARED_RESULTS_FILE, 'utf-8')); } catch {} } existing.platform = this.platform; existing.results.push(...this.results); fs.writeFileSync(SHARED_RESULTS_FILE, JSON.stringify(existing, null, 2)); } static generateCombinedReport(reportName?: string): string { if (!fs.existsSync(SHARED_RESULTS_FILE)) return ''; const data = JSON.parse(fs.readFileSync(SHARED_RESULTS_FILE, 'utf-8')); const rawResults: TestResult[] = data.results || []; // 去重:同 (suite + 用例名) 保留**最新一条**(重跑/补跑结果覆盖旧结果)。 // BLE/WiFi 双协议用例名含 [ble]/[wifi] 不同 → 不会被误并;补跑的失败/未执行用例自然覆盖昨晚记录。 const dedup = new Map(); for (const r of rawResults) dedup.set(`${r.suite}||${r.name}`, r); const results: TestResult[] = Array.from(dedup.values()); const platform: string = data.platform || 'UNKNOWN'; const reportDir = path.resolve(__dirname, '../reports'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const prefix = reportName || 'Bot_All'; const reportFile = path.join(reportDir, `${prefix}_${timestamp}.html`); const passed = results.filter(r => r.status === 'PASS').length; const failed = results.filter(r => r.status === 'FAIL').length; const skipped = results.filter(r => r.status === 'SKIP').length; const total = results.length; const effective = total - skipped; const passRate = effective > 0 ? ((passed / effective) * 100).toFixed(1) : '0.0'; const failRate = effective > 0 ? ((failed / effective) * 100).toFixed(1) : '0.0'; const totalMs = results.reduce((sum, r) => sum + r.duration, 0); const totalMin = (totalMs / 60000).toFixed(1); const totalDurLabel = totalMs >= 3600000 ? `${(totalMs / 3600000).toFixed(2)}h` : `${totalMin}min`; const passAngle = effective > 0 ? (passed / effective) * 360 : 0; const failAngle = effective > 0 ? (failed / effective) * 360 : 0; const conicGrad = skipped > 0 ? `conic-gradient(#97cc64 0deg ${passAngle}deg, #fd5a3e ${passAngle}deg ${passAngle + failAngle}deg, #555 ${passAngle + failAngle}deg 360deg)` : `conic-gradient(#97cc64 0deg ${passAngle}deg, #fd5a3e ${passAngle}deg 360deg)`; // Three-level hierarchy: Product → Module → Case // Suite naming convention: "Bot_Connect" → product="Bot", module="Connect" const productMap: Record> = {}; results.forEach(r => { const parts = r.suite.split('_'); const product = parts[0] || 'Unknown'; const module = parts.slice(1).join('_') || r.suite; if (!productMap[product]) productMap[product] = {}; if (!productMap[product][module]) productMap[product][module] = []; productMap[product][module].push(r); }); const productSections = Object.entries(productMap).map(([product, modules]) => { const productResults = Object.values(modules).flat(); const pPassed = productResults.filter(r => r.status === 'PASS').length; const pFailed = productResults.filter(r => r.status === 'FAIL').length; const pSkipped = productResults.filter(r => r.status === 'SKIP').length; const pTotal = productResults.length; const pEffective = pTotal - pSkipped; const pRate = pEffective > 0 ? ((pPassed / pEffective) * 100).toFixed(0) : '0'; const pDur = (productResults.reduce((s, r) => s + r.duration, 0) / 60000).toFixed(1); const pStatus = pFailed > 0 ? 'fail' : 'pass'; const moduleSections = Object.entries(modules).map(([module, items]) => { const mPassed = items.filter(r => r.status === 'PASS').length; const mFailed = items.filter(r => r.status === 'FAIL').length; const mSkipped = items.filter(r => r.status === 'SKIP').length; const mTotal = items.length; const mEffective = mTotal - mSkipped; const mRate = mEffective > 0 ? ((mPassed / mEffective) * 100).toFixed(0) : '0'; const mDur = (items.reduce((s, r) => s + r.duration, 0) / 60000).toFixed(1); const mStatus = mFailed > 0 ? 'fail' : 'pass'; const caseRows = items.map(t => { const cls = t.status === 'PASS' ? 'passed' : t.status === 'FAIL' ? 'failed' : 'skipped'; const dur = (t.duration / 1000).toFixed(1); const screenshotHtml = t.screenshot ? `
failure screenshot
` : ''; const autoExpand = t.status === 'FAIL' ? ' expanded' : ''; return `
${t.status} ${t.name} ${dur}s
${t.detail || 'No detail'}${screenshotHtml}
`; }).join('\n'); return `
${module} ${mPassed}/${mTotal} passed · ${mDur}min
${caseRows}
`; }).join('\n'); return `
${product} ${pPassed}/${pTotal} passed (${pRate}%) · ${Object.keys(modules).length} modules · ${pDur}min
${moduleSections}
`; }).join('\n'); const products = Object.keys(productMap); const moduleCount = Object.values(productMap).reduce((s, m) => s + Object.keys(m).length, 0); // ---- 结论 / 存在的问题 / 解决方案 ---- const esc = (s: string) => String(s).replace(/&/g, '&').replace(//g, '>'); const failedResults = results.filter(r => r.status === 'FAIL'); const failSuites = Array.from(new Set(failedResults.map(r => r.suite))); const SOL_KB: { re: RegExp; sol: string }[] = [ { re: /cannot navigate|addBto|添加页/i, sol: '导航到添加页失败(首页"+"被残留弹框/卡死页挡住):已加 navigateToAddPage 找不到"+"时重启 App 兜底 + dismissPopupIfPresent 识别连接失败弹框。' }, { re: /connection timeout|连接超时|未连上|配对未触发/i, sol: '连接/配对未触发:检查蓝牙与设备在线;已加"配对未发现设备就重按继电器重试(pairRetries)"。' }, { re: /Something went wrong|行程|calibrat|校验/i, sol: '行程校验异常:需设备装在真实导轨上;Auto-Calibrate Close 后重试 Finish 已加。' }, { re: /select category|no .* option|品类/i, sol: '品类选择失败:已用 UiScrollable.scrollIntoView 直达深层品类 + 重启兜底。' }, { re: /expected false to be true|not found|未在首页|未找到/i, sol: '设备未成功添加/首页未识别:核对配对触发(pairRetries)与设备名匹配(namePattern,需含十六进制后缀)。' }, ]; const conclusionLines: string[] = []; if (effective === 0) conclusionLines.push('本次无有效用例(全部跳过)。'); else if (failed === 0) conclusionLines.push(`全部 ${effective} 条有效用例通过,通过率 ${passRate}%,功能稳定。`); else conclusionLines.push(`通过率 ${passRate}%(${passed}/${effective}),失败 ${failed} 条,涉及:${failSuites.map(esc).join('、')}。`); if (skipped > 0) conclusionLines.push(`另有 ${skipped} 条跳过(未计入通过率)。`); const problemItems = failedResults.map(r => `
  • FAIL ${esc(r.suite)} / ${esc(r.name)}
    ${esc((r.detail || '无详情').slice(0, 400))}
    ${r.screenshot ? `
    ` : ''}
  • ` ).join('\n'); const solSet = new Set(); for (const r of failedResults) { const hit = SOL_KB.find(x => x.re.test(`${r.name} ${r.detail || ''}`)); solSet.add(hit ? hit.sol : '查看该用例的失败详情/截图定位原因。'); } const conclusionHtml = `
      ${conclusionLines.map(c => `
    • ${c}
    • `).join('')}
    `; const problemsHtml = failedResults.length ? `
      ${problemItems}
    ` : `

    无失败用例 ✓

    `; const solutionsHtml = solSet.size ? `
      ${Array.from(solSet).map(s => `
    • ${esc(s)}
    • `).join('')}
    ` : `

    无需处理,继续监控。

    `; const html = ` ${prefix} Test Report

    ${prefix} UI Automation Report

    ${platform}  |  ${new Date().toLocaleString('zh-CN')}
    ${passRate}%
    Pass Rate
    Passed ${passed} Failed ${failed} ${skipped > 0 ? `Skip ${skipped}` : ''}
    ${effective}
    Effective
    ${passed}
    Passed
    ${failed}
    Failed
    ${passRate}%
    Pass Rate
    ${failRate}%
    Fail Rate
    ${skipped > 0 ? skipped + ' skip / ' : ''}${totalDurLabel}
    ${skipped > 0 ? 'Skip / ' : ''}Duration
    Products (${products.length}) · ${moduleCount} Modules · ${total} Cases
    ${productSections}
    结论 (Conclusion)
    ${conclusionHtml}
    存在的问题 (Problems)
    ${problemsHtml}
    解决方案 (Solutions)
    ${solutionsHtml}
    `; fs.writeFileSync(reportFile, html); if (!process.env.SOAK_MODE) fs.unlinkSync(SHARED_RESULTS_FILE); // soak 模式保留,累积全程结果 console.log('\n' + '='.repeat(60)); console.log(' 全量测试报告'); console.log('='.repeat(60)); console.log(` 平台: ${platform} | 总耗时: ${totalDurLabel}`); console.log(` 通过: ${passed}/${effective} (${passRate}%) | 失败: ${failed}/${effective} (${failRate}%) | 跳过: ${skipped}`); console.log('-'.repeat(60)); Object.entries(productMap).forEach(([product, modules]) => { console.log(` [${product}]`); Object.entries(modules).forEach(([module, items]) => { const sp = items.filter(r => r.status === 'PASS').length; console.log(` [${module}] ${sp}/${items.length} 通过`); items.forEach(r => { const icon = r.status === 'PASS' ? '✓' : r.status === 'FAIL' ? '✗' : '○'; console.log(` ${icon} ${r.name} (${(r.duration / 1000).toFixed(1)}s)`); }); }); }); console.log('='.repeat(60)); console.log(` 报告文件: ${reportFile}`); console.log('='.repeat(60)); return reportFile; } static clearSharedResults() { if (fs.existsSync(SHARED_RESULTS_FILE)) fs.unlinkSync(SHARED_RESULTS_FILE); } }