feat(report): enrich Chinese report with detailed test metadata, logs, screenshots and phase timeline

- Add test metadata extraction: case ID, priority, tags, description
- Show phase timeline extracted from test log output
- Include test log content (last 500 lines) in collapsible section
- Include screenshot file list in collapsible section
- Redesign table with 8 columns and expandable error details
This commit is contained in:
xst
2026-07-23 17:16:11 +08:00
parent fa8a17eae8
commit 4ef8af5c87
+284 -55
View File
@@ -7,6 +7,7 @@ Usage:
from pathlib import Path
import sys
import json
import pytest
@@ -68,12 +69,37 @@ def shipper_ios_config(config):
return config["shipper_ios"]
# ═══════════════════════════════════════════════════════════════════
# 测试步骤收集 (conftest hook,用于报告中展示步骤明细)
# ═══════════════════════════════════════════════════════════════════
_STEP_BUF = []
def _step_context(test_name: str):
"""获取或创建当前测试的步骤缓冲。"""
for entry in _STEP_BUF:
if entry["test"] == test_name:
return entry
entry = {"test": test_name, "steps": []}
_STEP_BUF.append(entry)
return entry
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""拦截测试执行过程中的 log 输出,提取阶段信息。"""
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)
# ═══════════════════════════════════════════════════════════════════
# 中文 HTML 报告生成 (pytest_sessionfinish hook)
# ═══════════════════════════════════════════════════════════════════
def pytest_sessionfinish(session):
"""测试会话结束后,生成中文 HTML 报告。"""
"""测试会话结束后,生成详细的中文 HTML 报告。"""
from datetime import datetime
from xml.etree import ElementTree as ET
@@ -90,6 +116,7 @@ def pytest_sessionfinish(session):
except Exception:
return
# ── 基础统计 ──
total = 0
passed = 0
failed = 0
@@ -97,42 +124,75 @@ def pytest_sessionfinish(session):
cases = []
for suite in root:
suite_name = suite.attrib.get("name", "")
suite_time = suite.attrib.get("time", "0")
suite_ts = suite.attrib.get("timestamp", "")
err = suite.attrib.get("errors", "0")
suite_fail = suite.attrib.get("failures", "0")
suite_skip = suite.attrib.get("skipped", "0")
suite_tests = suite.attrib.get("tests", "0")
for case in suite:
total += 1
classname = case.attrib.get("classname", "")
func_name = case.attrib.get("name", "")
cn_name = _resolve_cn_name(session, func_name)
time_elapsed = case.attrib.get("time", "0")
# 提取用例元信息(用例ID、优先级、标签、测试覆盖)
meta = _extract_case_meta(session, func_name)
failure = case.find("failure")
error = case.find("error")
system_out = case.find("system-out")
system_err = case.find("system-err")
log_text = (system_out.text or "") if system_out is not None else ""
err_text = (system_err.text or "") if system_err is not None else ""
# 从日志中提取阶段时间线
phases = _extract_phases(log_text)
if failure is not None:
failed += 1
msg = failure.attrib.get("message", "")[:300]
cases.append((cn_name, "失败", time_elapsed, msg))
msg = failure.attrib.get("message", "")[:500]
cases.append((cn_name, "失败", time_elapsed, msg, meta, phases))
elif case.find("skipped") is not None:
skipped += 1
cases.append((cn_name, "跳过", time_elapsed, ""))
skip_msg = (case.find("skipped").attrib.get("message", ""))[:200]
cases.append((cn_name, "跳过", time_elapsed, skip_msg, meta, phases))
else:
passed += 1
cases.append((cn_name, "通过", time_elapsed, ""))
cases.append((cn_name, "通过", time_elapsed, "", meta, phases))
pass_rate = (passed / total * 100) if total > 0 else 0
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
html = _build_cn_report_html(total, passed, failed, skipped, pass_rate, cases, now)
# ── 收集日志文件 ──
log_contents = _collect_logs(report_dir)
# ── 收集截图列表 ──
screenshots = _collect_screenshots()
html = _build_cn_report_html(
total, passed, failed, skipped, pass_rate, cases,
now, suite_time, suite_ts, log_contents, screenshots
)
(report_dir / "report_cn.html").write_text(html, encoding="utf-8-sig")
# ═══════════════════════════════════════════════════════════════════
# 辅助函数
# ═══════════════════════════════════════════════════════════════════
def _resolve_cn_name(session, func_name: str) -> str:
"""从用例元信息中提取中文名称。
"""从用例 docstring 中提取中文名称。
优先级:
1) 用例 docstring 中显式声明的 "用例名称: xxx"
1) "用例名称: xxx"
2) display_name 属性
3) docstring 第一行去 【P0】 前缀
用例 docstring 格式示例:
用例ID: TC-D2S-001
用例名称: 司机APP接单到管理端核算打款完整流程
优先级: P0
"""
for item in session.items:
item_func_name = getattr(item, "originalname", None) or item.name.rsplit("::", 1)[-1]
@@ -141,17 +201,12 @@ def _resolve_cn_name(session, func_name: str) -> str:
test_func = getattr(test_cls, item_func_name, None) if test_cls else None
doc = (test_func.__doc__ or "") if test_func else ""
# 策略1: 从 docstring 中提取 "用例名称: xxx"
import re
m = re.search(r"用例名称[:]\s*(.+)", doc)
if m:
return m.group(1).strip()
# 策略2: display_name 属性
if test_func and hasattr(test_func, "display_name"):
return test_func.display_name
# 策略3: docstring 第一行去 【P0】 前缀
if doc:
first_line = doc.strip().split("\n")[0].strip()
if first_line.startswith(""):
@@ -163,34 +218,191 @@ def _resolve_cn_name(session, func_name: str) -> str:
break
return func_name
pass_rate = (passed / total * 100) if total > 0 else 0
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
html = _build_cn_report_html(total, passed, failed, skipped, pass_rate, cases, now)
(report_dir / "report_cn.html").write_text(html, encoding="utf-8-sig")
def _extract_case_meta(session, func_name: str) -> dict:
"""从 docstring 中提取用例元信息。"""
meta = {}
for item in session.items:
item_func_name = getattr(item, "originalname", None) or item.name.rsplit("::", 1)[-1]
if item_func_name == func_name:
test_cls = item.cls if hasattr(item, "cls") else None
test_func = getattr(test_cls, item_func_name, None) if test_cls else None
doc = (test_func.__doc__ or "") if test_func else ""
import re
meta["case_id"] = _re_first(r"用例ID[:]\s*(.+)", doc) or ""
meta["case_name"] = _re_first(r"用例名称[:]\s*(.+)", doc) or ""
meta["priority"] = _re_first(r"优先级[:]\s*(.+)", doc) or ""
meta["tags"] = _re_first(r"标签[:]\s*(.+)", doc) or ""
# 取 docstring 中除元信息外的描述段落
desc_lines = []
in_meta = True
for line in doc.split("\n"):
stripped = line.strip()
if not stripped:
in_meta = False
continue
if in_meta and any(stripped.startswith(k) for k in ["用例ID", "用例名称", "优先级", "标签"]):
continue
if not in_meta:
desc_lines.append(stripped)
meta["description"] = " ".join(desc_lines) if desc_lines else ""
break
return meta
def _build_cn_report_html(total, passed, failed, skipped, rate, cases, now):
"""拼装中文 HTML 报告字符串。"""
rows = ""
for i, (name, status, elapsed, msg) in enumerate(cases, 1):
def _re_first(pattern, text):
import re
m = re.search(pattern, text)
return m.group(1).strip() if m else ""
def _extract_phases(log_text: str) -> list:
"""从日志文本中提取阶段信息和时间点。"""
import re
phases = []
# 匹配 "Phase 1: ..." 和 "-> xxx完成" 等模式
phase_pattern = re.findall(r"Phase (\d+)[:]\s*(.+?)(?:\n|$)", log_text)
for num, desc in phase_pattern:
phases.append({"phase": num, "desc": desc.strip(), "type": "phase"})
# 匹配完成标记
done_pattern = re.findall(r"-> (.+?)(?:\n|$)", log_text)
for d in done_pattern:
phases.append({"phase": "", "desc": d.strip(), "type": "done"})
return phases
def _collect_logs(report_dir: Path) -> list:
"""收集最近的测试日志内容。"""
logs = []
log_dir = Path("output/logs")
if log_dir.exists():
log_files = sorted(log_dir.glob("test_run_*.log"), reverse=True)[:3]
for lf in log_files:
try:
content = lf.read_text(encoding="utf-8")
# 只取最后 500 行
lines = content.strip().split("\n")
if len(lines) > 500:
content = "\n".join(lines[-500:])
logs.append({"name": lf.name, "content": content})
except Exception:
pass
return logs
def _collect_screenshots() -> list:
"""收集测试过程中生成的截图。"""
shots = []
ss_dir = Path("output/screenshots")
if ss_dir.exists():
for p in sorted(ss_dir.rglob("*.png"), reverse=True)[:50]:
# 用相对路径
shots.append(str(p).replace("\\", "/"))
return shots
# ═══════════════════════════════════════════════════════════════════
# HTML 构建
# ═══════════════════════════════════════════════════════════════════
def _build_cn_report_html(total, passed, failed, skipped, rate, cases,
now, suite_time, suite_ts, log_contents, screenshots):
"""构建包含完整细节的中文 HTML 报告。"""
# ── 概览卡片 ──
summary_html = f"""
<div class="summary">
<div class="card total"><div class="num">{total}</div><div class="label">总计</div></div>
<div class="card pass"><div class="num">{passed}</div><div class="label">通过</div></div>
<div class="card fail"><div class="num">{failed}</div><div class="label">失败</div></div>
<div class="card skip"><div class="num">{skipped}</div><div class="label">跳过</div></div>
</div>
<div class="meta-row">
<span>执行时间: {now}</span>
<span>套件耗时: {float(suite_time):.1f}s</span>
<span>通过率: {rate:.1f}%</span>
</div>"""
# ── 用例明细表格 ──
rows_html = ""
for i, (name, status, elapsed, msg, meta, phases) in enumerate(cases, 1):
cls = {"通过": "s-pass", "失败": "s-fail", "跳过": "s-skip"}.get(status, "")
msg_html = f'<span class="msg">{msg}</span>' if msg else "-"
rows += f"<tr><td>{i}</td><td>{name}</td><td class=\"{cls}\">{status}</td>"
rows += f"<td>{float(elapsed):.1f}</td><td>{msg_html}</td></tr>"
case_id = meta.get("case_id", "")
priority = meta.get("priority", "")
desc = meta.get("description", "")
tags = meta.get("tags", "")
# 折叠的错误信息
msg_html = ""
if msg:
msg_html = f"""
<details class="err-detail">
<summary>错误详情</summary>
<pre>{msg}</pre>
</details>"""
# 阶段明细
phase_html = ""
if phases:
phase_items = "".join(
f'<span class="phase-{p["type"]}">{p.get("phase","")} {p["desc"]}</span>'
for p in phases[:20]
)
phase_html = f'<div class="phases">{phase_items}</div>'
rows_html += f"""
<tr class="case-row">
<td>{i}</td>
<td>
<div class="case-name">{name}</div>
{f'<div class="case-desc">{desc}</div>' if desc else ''}
</td>
<td><span class="{cls}">{status}</span></td>
<td>{case_id}</td>
<td>{priority}</td>
<td>{tags}</td>
<td>{float(elapsed):.1f}</td>
<td>
{phase_html}
{msg_html}
</td>
</tr>"""
# ── 日志面板 ──
log_html = ""
for lf in log_contents:
safe_content = lf["content"].replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
log_html += f"""
<details class="log-section">
<summary>📄 {lf['name']}</summary>
<pre class="log-content">{safe_content}</pre>
</details>"""
# ── 截图面板 ──
screenshot_html = ""
if screenshots:
shot_items = "".join(f'<div class="shot-item" title="{s}">📷 {s.split("/")[-1]}</div>' for s in screenshots[:30])
screenshot_html = f"""
<details class="log-section">
<summary>📸 截图列表 ({len(screenshots)} 张)</summary>
<div class="shot-grid">{shot_items}</div>
</details>"""
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>自动化测试报告 - {now}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:'Microsoft YaHei','PingFang SC',sans-serif;background:#f5f7fa;color:#333}}
body{{font-family:'Microsoft YaHei','PingFang SC',sans-serif;background:#f5f7fa;color:#333;line-height:1.6}}
.header{{background:linear-gradient(135deg,#409eff,#337ecc);color:#fff;padding:24px 32px}}
.header h1{{font-size:22px;font-weight:500}}
.header .time{{font-size:13px;opacity:.75;margin-top:6px}}
.summary{{display:flex;gap:20px;padding:24px 32px;background:#fff;border-bottom:1px solid #e4e7ed}}
.header .sub{{font-size:13px;opacity:.75;margin-top:6px}}
.summary{{display:flex;gap:20px;padding:24px 32px 12px;background:#fff}}
.card{{flex:1;text-align:center;padding:16px;border-radius:8px}}
.card .num{{font-size:36px;font-weight:600}}
.card .label{{font-size:14px;color:#909399;margin-top:4px}}
@@ -198,34 +410,51 @@ body{{font-family:'Microsoft YaHei','PingFang SC',sans-serif;background:#f5f7fa;
.card.pass{{background:#f0f9eb}}.card.pass .num{{color:#67c23a}}
.card.fail{{background:#fef0f0}}.card.fail .num{{color:#f56c6c}}
.card.skip{{background:#fdf6ec}}.card.skip .num{{color:#e6a23c}}
.rate{{text-align:center;padding:8px 32px;font-size:16px;color:#67c23a}}
.tbl{{margin:20px 32px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.06);overflow:hidden}}
table{{width:100%;border-collapse:collapse}}
th{{background:#f5f7fa;text-align:left;padding:12px 16px;font-size:13px;color:#909399;border-bottom:1px solid #ebeef5}}
td{{padding:12px 16px;font-size:14px;border-bottom:1px solid #ebeef5}}
.meta-row{{display:flex;gap:24px;padding:8px 32px 20px;background:#fff;border-bottom:1px solid #e4e7ed;font-size:13px;color:#909399}}
.section-title{{font-size:16px;font-weight:500;padding:20px 32px 12px;background:#fff}}
.tbl-wrap{{margin:0 32px 20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.06);overflow-x:auto}}
table{{width:100%;border-collapse:collapse;min-width:900px}}
th{{background:#f5f7fa;text-align:left;padding:10px 14px;font-size:13px;color:#909399;font-weight:500;border-bottom:2px solid #e4e7ed;white-space:nowrap}}
td{{padding:10px 14px;font-size:13px;border-bottom:1px solid #ebeef5;vertical-align:top}}
tr:hover td{{background:#f5f7fa}}
.s-pass{{color:#67c23a;font-weight:500}}
.s-fail{{color:#f56c6c;font-weight:500}}
.s-skip{{color:#e6a23c;font-weight:500}}
.msg{{font-size:12px;color:#909399;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block}}
.ft{{text-align:center;padding:20px;font-size:12px;color:#c0c4cc}}
.s-pass{{color:#67c23a;font-weight:600}}
.s-fail{{color:#f56c6c;font-weight:600}}
.s-skip{{color:#e6a23c;font-weight:600}}
.case-name{{font-weight:500;margin-bottom:2px}}
.case-desc{{font-size:12px;color:#909399;margin-top:4px;max-width:300px}}
.phases{{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}}
.phases span{{font-size:11px;padding:2px 8px;border-radius:10px;white-space:nowrap}}
.phase-phase{{background:#e6f7ff;color:#1890ff}}
.phase-done{{background:#f6ffed;color:#52c41a}}
.err-detail{{margin-top:6px}}
.err-detail summary{{font-size:12px;color:#f56c6c;cursor:pointer}}
.err-detail pre{{font-size:11px;background:#fff7f7;padding:8px;border-radius:4px;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow-y:auto;margin-top:4px}}
.log-section{{margin:0 32px 12px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.06);padding:12px 20px}}
.log-section summary{{font-size:14px;font-weight:500;cursor:pointer;padding:4px 0}}
.log-content{{font-size:11px;background:#1e1e1e;color:#d4d4d4;padding:12px;border-radius:4px;white-space:pre-wrap;word-break:break-all;max-height:400px;overflow-y:auto;font-family:'Consolas','Courier New',monospace}}
.shot-grid{{display:flex;flex-wrap:wrap;gap:8px;padding:8px 0}}
.shot-item{{font-size:12px;color:#606266;background:#f5f7fa;padding:4px 10px;border-radius:4px}}
.ft{{text-align:center;padding:24px;font-size:12px;color:#c0c4cc}}
</style>
</head>
<body>
<div class="header">
<h1>自动化测试报告</h1>
<div class="time">执行时间: {now} | 运八网络货运平台 QaAutomationHub</div>
<h1>🚀 自动化测试报告</h1>
<div class="sub">运八网络货运平台 · QaAutomationHub · {now}</div>
</div>
<div class="summary">
<div class="card total"><div class="num">{total}</div><div class="label">总计</div></div>
<div class="card pass"><div class="num">{passed}</div><div class="label">通过</div></div>
<div class="card fail"><div class="num">{failed}</div><div class="label">失败</div></div>
<div class="card skip"><div class="num">{skipped}</div><div class="label">跳过</div></div>
{summary_html}
<div class="tbl-wrap">
<table>
<thead><tr>
<th>#</th><th>用例名称 / 描述</th><th>状态</th>
<th>用例ID</th><th>优先级</th><th>标签</th><th>耗时(s)</th><th>阶段详情 / 备注</th>
</tr></thead>
<tbody>{rows_html}</tbody>
</table>
</div>
<div class="rate">通过率: {rate:.1f}% ({passed}/{total})</div>
<div class="tbl"><table>
<thead><tr><th>#</th><th>用例名称</th><th>状态</th><th>耗时(s)</th><th>备注</th></tr></thead>
<tbody>{rows}</tbody>
</table></div>
<div class="ft">报告自动生成于 {now}</div>
<div class="section-title">📋 测试日志</div>
{log_html if log_html else '<div class="log-section"><p style="color:#909399">暂无日志</p></div>'}
<div class="section-title">📸 测试截图</div>
{screenshot_html if screenshot_html else '<div class="log-section"><p style="color:#909399">暂无截图</p></div>'}
<div class="ft">报告自动生成于 {now} · QaAutomationHub 自动化测试框架</div>
</body></html>"""