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) { this.suite = 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 results: TestResult[] = data.results || []; 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 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}
`; fs.writeFileSync(reportFile, html); fs.unlinkSync(SHARED_RESULTS_FILE); 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); } }