Files
Yb-QaAutomationHub/tests/conftest.py
T
xst f23051a876 feat(report): embed screenshots as base64 in report_cn.html with click-to-zoom preview
- Convert PNG screenshots to base64 and embed directly in HTML
- Grid gallery layout with thumbnail previews
- Click to expand/zoom to fullscreen overlays
- No external file dependencies — report is self-contained
2026-07-23 17:59:42 +08:00

486 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""根级 conftest — 全局 fixture:配置加载、日志、中文 HTML 报告生成。
Usage:
def test_xxx(admin_config): # 管理端配置
def test_xxx(driver_android_config): # 司机安卓配置
"""
from pathlib import Path
import sys
import json
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
@pytest.fixture(scope="session")
def config():
"""全局配置(session 级,只加载一次)。"""
from tests.config.test_config import load_config
return load_config()
@pytest.fixture(scope="function")
def logger(request):
"""每个测试函数的 logger。"""
from tests.utils.logger import get_logger
module_name = request.module.__name__.replace("tests.", "")
return get_logger(module_name)
# ═══════════════════════════════════════════════════════════════════
# 各端配置 fixture(从 config 中摘取对应 section
# ═══════════════════════════════════════════════════════════════════
@pytest.fixture(scope="session")
def admin_config(config):
"""管理端 (Playwright) 配置。"""
return config["admin"]
@pytest.fixture(scope="session")
def mini_program_config(config):
"""司机端小程序 (Playwright) 配置。"""
return config["mini_program"]
@pytest.fixture(scope="session")
def driver_android_config(config):
"""司机安卓端 (Appium) 配置。"""
return config["driver_android"]
@pytest.fixture(scope="session")
def shipper_android_config(config):
"""货主安卓端 (Appium) 配置。"""
return config["shipper_android"]
@pytest.fixture(scope="session")
def driver_ios_config(config):
"""司机苹果端 (Appium) 配置。"""
return config["driver_ios"]
@pytest.fixture(scope="session")
def shipper_ios_config(config):
"""货主苹果端 (Appium) 配置。"""
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 报告。"""
from datetime import datetime
from xml.etree import ElementTree as ET
report_dir = Path("output/reports")
report_dir.mkdir(parents=True, exist_ok=True)
junit_path = report_dir / "junit.xml"
if not junit_path.exists():
return
try:
tree = ET.parse(junit_path)
root = tree.getroot()
except Exception:
return
# ── 基础统计 ──
total = 0
passed = 0
failed = 0
skipped = 0
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", "")[:500]
cases.append((cn_name, "失败", time_elapsed, msg, meta, phases))
elif case.find("skipped") is not None:
skipped += 1
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, "", meta, phases))
pass_rate = (passed / total * 100) if total > 0 else 0
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# ── 收集日志文件 ──
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) "用例名称: xxx"
2) display_name 属性
3) docstring 第一行去 【P0】 前缀
"""
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
m = re.search(r"用例名称[:]\s*(.+)", doc)
if m:
return m.group(1).strip()
if test_func and hasattr(test_func, "display_name"):
return test_func.display_name
if doc:
first_line = doc.strip().split("\n")[0].strip()
if first_line.startswith("【"):
idx = first_line.find("】")
if idx > 0:
first_line = first_line[idx + 1:]
if first_line and len(first_line) > 2:
return first_line
break
return func_name
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 _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:
"""收集测试过程中生成的截图,转为 base64 内嵌到 HTML 中。"""
import base64
shots = []
ss_dir = Path("output/screenshots")
if ss_dir.exists():
for p in sorted(ss_dir.rglob("*.png"), reverse=True)[:30]:
try:
data = p.read_bytes()
b64 = base64.b64encode(data).decode("ascii")
shots.append({
"name": p.name,
"path": str(p).replace("\\", "/"),
"b64": b64,
})
except Exception:
pass
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, "")
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_cards = ""
for s in screenshots[:20]:
shot_cards += f"""
<div class="shot-card">
<div class="shot-thumb" onclick="this.classList.toggle('expanded')">
<img src="data:image/png;base64,{s['b64']}" loading="lazy" alt="{s['name']}">
</div>
<div class="shot-label">{s['name']}</div>
</div>"""
screenshot_html = f"""
<details class="log-section" open>
<summary>📸 测试截图 ({len(screenshots)} 张) — 点击可放大</summary>
<div class="shot-gallery">{shot_cards}</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;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 .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}}
.card.total{{background:#f0f5ff}}.card.total .num{{color:#409eff}}
.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}}
.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: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-gallery{{display:flex;flex-wrap:wrap;gap:16px;padding:8px 0}}
.shot-card{{width:280px;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08);background:#fff;transition:box-shadow .2s}}
.shot-card:hover{{box-shadow:0 4px 16px rgba(0,0,0,.15)}}
.shot-thumb{{width:100%;height:200px;overflow:hidden;cursor:pointer;position:relative;background:#f5f7fa}}
.shot-thumb img{{width:100%;height:100%;object-fit:contain;display:block}}
.shot-thumb.expanded{{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:90vw;height:90vh;z-index:9999;background:rgba(0,0,0,.85)}}
.shot-thumb.expanded img{{width:auto;height:auto;max-width:100%;max-height:100%;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}}
.shot-thumb::after{{content:'🔍 点击放大';position:absolute;bottom:0;left:0;right:0;padding:6px;background:rgba(0,0,0,.5);color:#fff;font-size:11px;text-align:center;opacity:0;transition:opacity .2s}}
.shot-thumb:hover::after{{opacity:1}}
.shot-label{{font-size:11px;color:#606266;padding:6px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}
.ft{{text-align:center;padding:24px;font-size:12px;color:#c0c4cc}}
</style>
</head>
<body>
<div class="header">
<h1>🚀 自动化测试报告</h1>
<div class="sub">运八网络货运平台 · QaAutomationHub · {now}</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="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>"""