refactor(tests): restructure into layered POM architecture with Allure reporting
- Split framework into core/ (config, logger, db, driver factories, screenshot, allure integration) and pages/ (PO layer: base/admin/driver); flatten test files per platform (web/admin, app/driver_android, cross_end) - Slim root conftest.py 656 -> ~100 lines: drop inline CN HTML report, add Allure severity mapping (p0/p1/p2 -> blocker/normal/minor) and environment.properties; support both tests.*-prefixed and bare imports - Preserve TC-D2S-001/002 cross-end E2E cases with business logic unchanged, add allure epic/feature/story/step annotations - Deduplicate: single makereport hook, shared failure-screenshot helpers, single SCREENSHOTS_DIR constant; deterministic auth-state role loading - Move DB credentials from db_helper.py into test_config.yaml (database section) - Move pytest.ini to repo root; switch reporting to allure-pytest (drop pytest-html/junitxml); update requirements.txt accordingly - Delete deprecated scaffolding: test_freight_full_flow + 6 stale page objects, iOS/shipper empty shells, dead utils - Fix flaky vxe-table action-row wait: wait for a visible td instead of hidden placeholder rows (fixed shipper-payment timeouts) - Verified end-to-end against live env: both E2E cases pass (2 passed 3m12s); run artifacts (logs/screenshots/allure) included per repo convention
This commit is contained in:
+45
-428
@@ -1,30 +1,38 @@
|
||||
"""根级 conftest — 全局 fixture:配置加载、日志、中文 HTML 报告生成。
|
||||
"""全局 conftest — 配置/日志 fixtures + Allure 集成。
|
||||
|
||||
架构分层 (POM):
|
||||
core/ 框架层: 配置、日志、DB、驱动工厂、截图、Allure 集成
|
||||
pages/ PO 层: 页面对象,与用例分离
|
||||
config/ YAML 配置
|
||||
|
||||
Usage:
|
||||
def test_xxx(admin_config): # 管理端配置
|
||||
def test_xxx(driver_android_config): # 司机安卓配置
|
||||
def test_xxx(admin_config): # 管理端配置
|
||||
def test_xxx(driver_android_config): # 司机安卓配置
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import allure
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
# 兼容两种 import 风格: from tests.core.xxx (项目根为源根)
|
||||
# 与 from core.xxx (tests/ 为源根, 如 PyCharm 将 tests/ 标记为 Sources Root)
|
||||
if (tests_root := str(Path(__file__).parent)) not in sys.path:
|
||||
sys.path.insert(0, tests_root)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def config():
|
||||
"""全局配置(session 级,只加载一次)。"""
|
||||
from tests.config.test_config import load_config
|
||||
from tests.core.config_loader import load_config
|
||||
return load_config()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def logger(request):
|
||||
"""每个测试函数的 logger。"""
|
||||
from tests.utils.logger import get_logger
|
||||
from tests.core.logger import get_logger
|
||||
module_name = request.module.__name__.replace("tests.", "")
|
||||
return get_logger(module_name)
|
||||
|
||||
@@ -41,7 +49,7 @@ def admin_config(config):
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def mini_program_config(config):
|
||||
"""司机端小程序 (Playwright) 配置。"""
|
||||
"""司机端小程序 (miniprogram-automator) 配置。"""
|
||||
return config["mini_program"]
|
||||
|
||||
|
||||
@@ -51,435 +59,44 @@ def driver_android_config(config):
|
||||
return config["driver_android"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def shipper_android_config(config):
|
||||
"""货主安卓端 (Appium) 配置。"""
|
||||
return config["shipper_android"]
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Allure 集成
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# pytest marker → Allure severity 自动映射(marker 是唯一事实源)
|
||||
_MARKER_SEVERITY = {
|
||||
"p0": allure.severity_level.BLOCKER,
|
||||
"p1": allure.severity_level.NORMAL,
|
||||
"p2": allure.severity_level.MINOR,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def driver_ios_config(config):
|
||||
"""司机苹果端 (Appium) 配置。"""
|
||||
return config["driver_ios"]
|
||||
def pytest_collection_modifyitems(items):
|
||||
"""按 p0/p1/p2 marker 自动设置 Allure severity。"""
|
||||
for item in items:
|
||||
for marker, severity in _MARKER_SEVERITY.items():
|
||||
if item.get_closest_marker(marker):
|
||||
item.add_marker(allure.severity(severity))
|
||||
break
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def shipper_ios_config(config):
|
||||
"""货主苹果端 (Appium) 配置。"""
|
||||
return config["shipper_ios"]
|
||||
def pytest_sessionfinish(session):
|
||||
"""向 allure-results 写入 environment.properties。"""
|
||||
# 注意: allure-pytest 的 --alluredir 选项 dest 为 allure_report_dir
|
||||
results_dir = session.config.getoption("allure_report_dir", default=None)
|
||||
if not results_dir:
|
||||
return
|
||||
from tests.core.allure_support import write_environment_properties
|
||||
from tests.core.config_loader import load_config
|
||||
write_environment_properties(results_dir, load_config())
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 测试步骤收集 (conftest hook,用于报告中展示步骤明细)
|
||||
# Report hook — 让 request.node.rep_call.failed 可用于失败截图
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
_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)
|
||||
@pytest.hookimpl(tryfirst=True, 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
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>"""
|
||||
|
||||
Reference in New Issue
Block a user