AI_UIAutomation/utils/test-reporter.ts

339 lines
16 KiB
TypeScript

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<string, Record<string, TestResult[]>> = {};
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 ? `<div class="case-screenshot"><img src="data:image/png;base64,${t.screenshot}" alt="failure screenshot"/></div>` : '';
const autoExpand = t.status === 'FAIL' ? ' expanded' : '';
return `<div class="case${autoExpand}" onclick="this.classList.toggle('expanded')">
<div class="case-row">
<span class="badge ${cls}">${t.status}</span>
<span class="case-title">${t.name}</span>
<span class="case-time">${dur}s</span>
</div>
<div class="case-body">${t.detail || 'No detail'}${screenshotHtml}</div>
</div>`;
}).join('\n');
return `<div class="module ${mStatus}" data-open="true">
<div class="module-head" onclick="var s=this.parentElement;s.dataset.open=s.dataset.open==='true'?'false':'true'">
<svg class="chevron" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>
<span class="module-dot ${mStatus}"></span>
<span class="module-title">${module}</span>
<span class="module-meta">${mPassed}/${mTotal} passed &middot; ${mDur}min</span>
<div class="module-bar"><div class="module-bar-fill" style="width:${mRate}%"></div></div>
</div>
<div class="module-body">${caseRows}</div>
</div>`;
}).join('\n');
return `<div class="product ${pStatus}" data-open="true">
<div class="product-head" onclick="var s=this.parentElement;s.dataset.open=s.dataset.open==='true'?'false':'true'">
<svg class="chevron" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>
<span class="product-dot ${pStatus}"></span>
<span class="product-title">${product}</span>
<span class="product-meta">${pPassed}/${pTotal} passed (${pRate}%) &middot; ${Object.keys(modules).length} modules &middot; ${pDur}min</span>
<div class="product-bar"><div class="product-bar-fill" style="width:${pRate}%"></div></div>
</div>
<div class="product-body">${moduleSections}</div>
</div>`;
}).join('\n');
const products = Object.keys(productMap);
const moduleCount = Object.values(productMap).reduce((s, m) => s + Object.keys(m).length, 0);
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>${prefix} Test Report</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#262b34;color:#d5d5d5;min-height:100vh}
.page{max-width:1160px;margin:0 auto;padding:32px 24px}
.header{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px}
.header h1{font-size:22px;color:#fff;font-weight:700}
.header .info{font-size:12px;color:#8e8e8e}
.overview{display:grid;grid-template-columns:220px 1fr;gap:24px;margin-bottom:32px}
.chart-box{background:#2d333b;border-radius:12px;padding:28px 20px;display:flex;flex-direction:column;align-items:center;gap:16px}
.donut{width:150px;height:150px;border-radius:50%;background:${conicGrad};position:relative;display:flex;align-items:center;justify-content:center}
.donut::after{content:'';position:absolute;width:96px;height:96px;border-radius:50%;background:#2d333b}
.donut .inner{position:relative;z-index:1;text-align:center}
.donut .inner .num{font-size:30px;font-weight:800;color:#97cc64}
.donut .inner .txt{font-size:10px;color:#8e8e8e;text-transform:uppercase;letter-spacing:1px}
.legend{display:flex;gap:14px;font-size:11px;color:#aaa}
.legend i{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;vertical-align:middle}
.cards{display:grid;grid-template-columns:repeat(3,1fr);grid-template-rows:repeat(2,1fr);gap:12px}
.card{background:#2d333b;border-radius:10px;padding:18px 14px;text-align:center;border:1px solid #373e47}
.card .v{font-size:30px;font-weight:700;line-height:1.2}
.card .l{font-size:11px;color:#8e8e8e;margin-top:4px;text-transform:uppercase;letter-spacing:.5px}
.card.c-pass .v{color:#97cc64}
.card.c-fail .v{color:#fd5a3e}
.card.c-total .v{color:#6db3f2}
.card.c-rate .v{color:#97cc64}
.card.c-failrate .v{color:#fd5a3e}
.card.c-time .v{color:#d4a5f5;font-size:22px}
.section-hd{font-size:15px;font-weight:600;color:#fff;margin-bottom:10px}
/* Product level (top) */
.product{background:#2d333b;border-radius:12px;margin-bottom:12px;border:1px solid #373e47;overflow:hidden}
.product-head{display:flex;align-items:center;gap:12px;padding:16px 20px;cursor:pointer;transition:background .15s;background:#2a3040}
.product-head:hover{background:#323a4a}
.product-dot{width:11px;height:11px;border-radius:50%;flex-shrink:0}
.product-dot.pass{background:#97cc64}
.product-dot.fail{background:#fd5a3e}
.product-title{font-weight:700;font-size:16px;flex:1;color:#fff}
.product-meta{font-size:11px;color:#8e8e8e;white-space:nowrap}
.product-bar{width:80px;height:5px;background:#fd5a3e;border-radius:3px;overflow:hidden;margin-left:8px}
.product-bar-fill{height:100%;background:#97cc64;border-radius:3px}
.product-body{display:none;padding:8px 16px 16px 16px}
.product[data-open="true"] .product-body{display:block}
/* Module level (middle) */
.module{background:#323a45;border-radius:10px;margin-bottom:6px;border:1px solid #3d4550;overflow:hidden}
.module-head{display:flex;align-items:center;gap:10px;padding:12px 16px;cursor:pointer;transition:background .15s}
.module-head:hover{background:#3a4350}
.module-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0}
.module-dot.pass{background:#97cc64}
.module-dot.fail{background:#fd5a3e}
.module-title{font-weight:600;font-size:14px;flex:1}
.module-meta{font-size:11px;color:#888;white-space:nowrap}
.module-bar{width:56px;height:4px;background:#fd5a3e;border-radius:2px;overflow:hidden;margin-left:8px}
.module-bar-fill{height:100%;background:#97cc64;border-radius:2px}
.module-body{display:none;padding:4px 14px 12px 36px}
.module[data-open="true"] .module-body{display:block}
/* Case level (leaf) */
.chevron{width:14px;height:14px;fill:none;stroke:#888;stroke-width:2;transition:transform .2s}
.product[data-open="true"]>.product-head .chevron{transform:rotate(90deg)}
.module[data-open="true"]>.module-head .chevron{transform:rotate(90deg)}
.case{border-left:2px solid #3d4550;padding:7px 0 7px 14px;cursor:pointer;border-radius:0 6px 6px 0;margin-bottom:2px}
.case:hover{background:#3a4350}
.case.expanded{border-left-color:#97cc64}
.case-row{display:flex;align-items:center;gap:10px}
.badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:3px;text-transform:uppercase;letter-spacing:.3px;min-width:42px;text-align:center}
.badge.passed{background:rgba(151,204,100,.12);color:#97cc64}
.badge.failed{background:rgba(253,90,62,.12);color:#fd5a3e}
.badge.skipped{background:rgba(255,179,71,.12);color:#ffb347}
.case-title{flex:1;font-size:13px}
.case-time{font-size:11px;color:#888;min-width:56px;text-align:right}
.case-body{display:none;margin-top:6px;padding:8px 12px;background:#1e2228;border-radius:6px;font-size:12px;color:#aaa;line-height:1.6}
.case.expanded .case-body{display:block}
.case-screenshot{margin-top:8px}
.case-screenshot img{max-width:300px;border-radius:6px;border:1px solid #3d4550}
.footer{text-align:center;padding:24px;color:#555;font-size:11px}
</style>
</head>
<body>
<div class="page">
<div class="header">
<h1>${prefix} UI Automation Report</h1>
<div class="info">${platform} &nbsp;|&nbsp; ${new Date().toLocaleString('zh-CN')}</div>
</div>
<div class="overview">
<div class="chart-box">
<div class="donut"><div class="inner"><div class="num">${passRate}%</div><div class="txt">Pass Rate</div></div></div>
<div class="legend">
<span><i style="background:#97cc64"></i>Passed ${passed}</span>
<span><i style="background:#fd5a3e"></i>Failed ${failed}</span>
${skipped > 0 ? `<span><i style="background:#ffb347"></i>Skip ${skipped}</span>` : ''}
</div>
</div>
<div class="cards">
<div class="card c-total"><div class="v">${effective}</div><div class="l">Effective</div></div>
<div class="card c-pass"><div class="v">${passed}</div><div class="l">Passed</div></div>
<div class="card c-fail"><div class="v">${failed}</div><div class="l">Failed</div></div>
<div class="card c-rate"><div class="v">${passRate}%</div><div class="l">Pass Rate</div></div>
<div class="card c-failrate"><div class="v">${failRate}%</div><div class="l">Fail Rate</div></div>
<div class="card c-time"><div class="v">${skipped > 0 ? skipped + ' skip / ' : ''}${totalDurLabel}</div><div class="l">${skipped > 0 ? 'Skip / ' : ''}Duration</div></div>
</div>
</div>
<div class="section-hd">Products (${products.length}) &middot; ${moduleCount} Modules &middot; ${total} Cases</div>
${productSections}
<div class="footer">Generated by AI_UIAutomation &middot; ${products.length} products &middot; ${moduleCount} modules &middot; ${total} cases</div>
</div>
</body>
</html>`;
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);
}
}