# -*- coding: utf-8 -*-
"""
HTMLTestRunnerNew
=================
基于 unittest 的 HTML 测试报告生成器,支持失败自动截图、用例重试覆盖、详情折叠/筛选。
────────────────────────────────────────────────────────────────────────────
基础用法(保持向后兼容,无需任何额外参数)
────────────────────────────────────────────────────────────────────────────
from HTMLTestRunnerNew import HTMLTestRunner
with open('Report/Reports/Report.html', 'w', encoding='utf-8') as f:
runner = HTMLTestRunner(
stream=f,
title='自动化测试报告',
description='SIT 环境',
verbosity=1,
)
runner.run(suite)
────────────────────────────────────────────────────────────────────────────
大规模执行优化(≥100 个失败用例时强烈建议)
────────────────────────────────────────────────────────────────────────────
默认 embed_screenshots=True 把每张截图 base64 内嵌进 HTML,单文件可独立分享,
但 100 个失败用例 × 5 张截图 ≈ 200MB,浏览器可能打不开。
切到外链模式 (HTML 仅几十 KB,截图按需懒加载):
runner = HTMLTestRunner(
stream=f,
title='...',
embed_screenshots=False, # 不内嵌
report_dir=os.path.dirname(reportsPath), # HTML 所在目录的绝对路径
)
外链模式下:
- HTML 里的 <img src> 会自动算成相对路径 (例如 "../Screenshots/xxx.png")
- 分享报告时必须把整个 Report/ 目录一起给,单独发 HTML 看不到图
- 浏览器会自动 lazy-load,滚动到的截图才解码
────────────────────────────────────────────────────────────────────────────
自动失败截图机制(无侵入测试代码)
────────────────────────────────────────────────────────────────────────────
当用例失败/错误时,会自动尝试调用 test 实例上的 webdriver 截图,
覆盖测试代码本身没主动截图(只 raise AssertionError)的场景。
工作前提(约定):
1. 测试用例 setUp 里赋值 driver 属性,例如:
def setUp(self):
self.driver = TestSuite.admin_driver
2. driver 对象有以下方法之一:
- saveScreenshot(path) (项目自定义封装,优先调用)
- save_screenshot(path) (标准 selenium API,次选)
3. 截图会保存到:
Report/Screenshots/Failure_<test_name>_<timestamp_ms>.png
4. 截图日志格式:
[失败时刻] 屏幕截图成功, 存放位置 Report/Screenshots/Failure_xxx.png
5. 报告里这张截图会用红色边框标记 "失败时刻",置顶显示。
任何环节失败(无 driver / 浏览器已关闭 / 写文件失败)都会被静默吞掉,
不影响测试本身的执行。
────────────────────────────────────────────────────────────────────────────
其他特性
────────────────────────────────────────────────────────────────────────────
- 用例重试覆盖:与 FrameWorkTestSuite.run() 的 failedReexec 配合,
同一 test.id() 重复回调时覆盖之前的记录,报告里始终只显示最终结果。
- 4 种筛选 tab:概览(仅 class 汇总)/ 仅失败 / 仅错误 / 全部。
- 运行输出 / 错误堆栈一键复制按钮。
- 截图按时间倒序,"失败时刻" 截图特别标记。
- 滚动隔离:日志框内滚动不会带动整页滚动 (overscroll-behavior: contain)。
- 内嵌运行环境信息:Python 版本、平台、主机名。
"""
import base64
import datetime
import io
import json
import logging
import os
import platform
import re
import sys
import time
import unittest
from xml.sax import saxutils
try:
from logger import logger as Text
except ImportError:
Text = None
class OutputRedirector(object):
def __init__(self, fp):
self.fp = fp
def write(self, s):
self.fp.write(s)
def writelines(self, lines):
self.fp.writelines(lines)
def flush(self):
self.fp.flush()
stdout_redirector = OutputRedirector(sys.stdout)
stderr_redirector = OutputRedirector(sys.stderr)
class Template_mixin(object):
STATUS = {
0: 'pass',
1: 'fail',
2: 'error',
3: 'skip',
}
DEFAULT_TITLE = '自动化测试报告'
DEFAULT_DESCRIPTION = ''
HTML_TMPL = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%(title)s</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: "Microsoft Yahei", "Helvetica Neue", Arial, sans-serif;
background-color: #f0f2f5;
color: #333;
line-height: 1.6;
margin: 0;
padding: 0;
}
.report-header {
background: #1e293b;
color: white;
padding: 35px 32px;
text-align: center;
border-radius: 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.report-header h1 { margin-bottom: 8px; font-weight: 700; letter-spacing: 1px; }
.report-header .sub { color: rgba(255,255,255,0.7); font-size: 0.95rem; }
.container-main { max-width: 1600px; width: 100%%; margin: 0 auto; padding: 0 32px; }
.card-section {
background: white;
border-radius: 0;
padding: 24px 32px;
margin-bottom: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
}
.stat-cards { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; }
.stat-card {
position: relative; flex: 1; min-width: 180px; border-radius: 12px;
padding: 20px 22px; color: white; overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex; align-items: center; gap: 16px;
}
.stat-card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px rgba(0,0,0,0.15); }
.stat-card .sc-icon {
width: 48px; height: 48px; flex-shrink: 0;
background: rgba(255,255,255,0.18); border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem;
}
.stat-card .sc-body { flex: 1; min-width: 0; }
.stat-card .sc-label { font-size: 0.78rem; opacity: 0.88; margin-bottom: 2px;
text-transform: uppercase; letter-spacing: 0.05em; }
.stat-card .sc-num { font-size: 2rem; font-weight: 700; line-height: 1.1; }
.stat-card .sc-pct { font-size: 0.78rem; opacity: 0.85; margin-top: 4px; }
.stat-card .sc-watermark {
position: absolute; right: -12px; bottom: -16px;
font-size: 5rem; opacity: 0.08; line-height: 1; pointer-events: none;
}
.stat-card.total { background: linear-gradient(135deg, #334155, #475569); }
.stat-card.pass { background: linear-gradient(135deg, #059669, #10b981); }
.stat-card.fail { background: linear-gradient(135deg, #dc2626, #ef4444); }
.stat-card.error { background: linear-gradient(135deg, #d97706, #f59e0b); }
.stat-card.skip { background: linear-gradient(135deg, #6b7280, #9ca3af); }
.progress-wrap { margin-bottom: 16px; }
.progress { height: 10px; border-radius: 5px; background-color: #e5e7eb; overflow: hidden; }
.progress-bar { border-radius: 5px; transition: width 0.8s ease; }
.meta-grid {
display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px;
}
.meta-item {
display: flex; align-items: center; gap: 8px;
background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 8px;
padding: 8px 14px; font-size: 0.82rem;
}
.meta-item i { color: #64748b; font-size: 0.85rem; width: 14px; text-align: center; }
.meta-item .mk { color: #94a3b8; margin-right: 2px; }
.meta-item .mv { color: #1e293b; font-weight: 600; }
.filter-bar { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
.filter-btn {
padding: 7px 18px; border: 2px solid transparent; border-radius: 20px;
cursor: pointer; font-weight: 500; font-size: 0.9rem; transition: all 0.2s;
background: #f0f2f5; color: #555;
}
.filter-btn:hover { background: #e2e6ea; }
.filter-btn.active { color: white; border-color: transparent; }
.filter-btn.active.all { background: #334155; }
.filter-btn.active.fail { background: #dc2626; }
.filter-btn.active.error { background: #d97706; }
.filter-btn.active.pass { background: #059669; }
.filter-btn.active.skip { background: #6b7280; }
.result-table { width: 100%%; border-collapse: collapse; }
.result-table th {
background: #343a40; color: white; padding: 12px 10px;
font-weight: 600; font-size: 0.9rem; text-align: left;
}
.result-table th:first-child { border-radius: 0; }
.result-table th:last-child { border-radius: 0; }
.result-table td { padding: 10px; border-bottom: 1px solid #eee; font-size: 0.9rem; vertical-align: top; }
.result-table tbody tr { transition: background-color 0.15s ease; }
.result-table tbody tr:hover { background-color: #f8f9fa; }
.result-table tbody tr.row-pass { border-left: 6px solid #059669; background-color: #f0fdf4; }
.result-table tbody tr.row-pass:hover { background-color: #dcfce7; }
.result-table tbody tr.row-fail { border-left: 6px solid #dc2626; background-color: #fff5f5; }
.result-table tbody tr.row-fail:hover { background-color: #fee2e2; }
.result-table tbody tr.row-error { border-left: 6px solid #d97706; background-color: #fffbeb; }
.result-table tbody tr.row-error:hover { background-color: #fef3c7; }
.class-row { background-color: #f8f9fa; font-weight: 600; cursor: pointer; }
.class-row:hover { background-color: #e9ecef; }
.status-tag { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }
.status-tag.pass { background: #d1fae5; color: #065f46; }
.status-tag.pass-strong { background: #059669; color: white; }
.status-tag.fail { background: #fee2e2; color: #991b1b; }
.status-tag.error { background: #fef3c7; color: #92400e; }
.status-tag.skip { background: #f3f4f6; color: #4b5563; }
.detail-toggle {
background: none; border: 1px solid #dee2e6; border-radius: 4px;
padding: 3px 10px; cursor: pointer; font-size: 0.8rem; color: #555;
transition: all 0.2s; display: inline-flex; align-items: center; gap: 4px;
}
.detail-toggle:hover { background: #e9ecef; border-color: #adb5bd; color: #333; }
.detail-toggle i { transition: transform 0.25s ease; display: inline-block; font-size: 0.7rem; }
.detail-toggle.expanded i { transform: rotate(180deg); }
.inline-detail-row { transition: all 0.2s ease; }
.inline-detail-row td { padding: 0 !important; border-bottom: 1px solid #dee2e6; background: #fafbfc; }
.inline-detail-container {
padding: 16px 22px; border-top: 1px dashed #ddd;
border-left: 3px solid #667eea;
}
.inline-tab-bar { display: flex; gap: 0; margin-bottom: 12px; border-bottom: 2px solid #e9ecef; }
.inline-tab-btn {
background: none; border: none; color: #666; padding: 8px 16px;
cursor: pointer; font-size: 0.85rem; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: all 0.2s;
}
.inline-tab-btn:hover { color: #333; }
.inline-tab-btn.active { color: #0f3460; border-bottom-color: #0f3460; font-weight: 600; }
.inline-tab-content { display: none; animation: fadeIn 0.2s ease; }
.inline-tab-content.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
.inline-pre {
margin: 0; white-space: pre-wrap; word-break: break-all;
font-family: Consolas, Monaco, "Courier New", monospace;
max-height: 680px; min-height: 155px; overflow-y: auto; font-size: 0.9rem;
background: #1e1e1e; color: #c75050; padding: 16px 20px;
border-radius: 6px; line-height: 1.7; border: 1px solid #333;
overscroll-behavior: contain;
}
.inline-pre::-webkit-scrollbar { width: 6px; }
.inline-pre::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
.inline-pre::-webkit-scrollbar-track { background: #2a2a2a; }
.pre-wrap { position: relative; }
.copy-btn {
position: absolute; top: 10px; right: 10px;
background: rgba(255,255,255,0.08); color: #ccc;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 5px; padding: 4px 10px; font-size: 0.78rem;
cursor: pointer; transition: all 0.15s; z-index: 2;
display: inline-flex; align-items: center; gap: 5px;
font-family: inherit;
}
.copy-btn:hover { background: rgba(255,255,255,0.16); color: #fff; }
.copy-btn.copied { background: #16a34a; color: #fff; border-color: #16a34a; }
.copy-btn i { font-size: 0.75rem; }
.copy-toast {
position: fixed; bottom: 90px; right: 30px;
background: #16a34a; color: white;
padding: 10px 18px; border-radius: 6px; font-size: 0.85rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
opacity: 0; transform: translateY(20px); transition: all 0.25s;
z-index: 10000; pointer-events: none;
}
.copy-toast.show { opacity: 1; transform: translateY(0); }
.screenshot-grid { display: flex; flex-wrap: wrap; gap: 15px; }
.screenshot-item {
position: relative; border: 1px solid #ddd; border-radius: 6px;
overflow: hidden; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
transition: transform 0.2s;
}
.screenshot-item.failure-shot { border: 2px solid #dc2626; box-shadow: 0 2px 12px rgba(220,38,38,0.25); }
.screenshot-item .shot-badge {
position: absolute; top: 6px; left: 6px; z-index: 2;
background: rgba(0,0,0,0.6); color: white;
font-size: 0.7rem; padding: 2px 6px; border-radius: 3px;
}
.screenshot-item.failure-shot .shot-badge {
background: #dc2626; font-weight: 600;
}
.screenshot-item:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.screenshot-thumb { display: block; width: 220px; height: 160px; object-fit: cover; cursor: zoom-in; }
.screenshot-name {
font-size: 0.75rem; color: #666; padding: 6px 8px;
background: #f8f9fa; border-top: 1px solid #eee;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.lightbox-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.92); z-index: 9999;
justify-content: center; align-items: center; cursor: zoom-out; padding: 20px;
}
.lightbox-overlay.active { display: flex; }
.lightbox-overlay img { max-width: 95%%; max-height: 95vh; object-fit: contain; border-radius: 4px; }
.charts-row { display: flex; gap: 24px; flex-wrap: wrap; align-items: stretch; }
.chart-panel {
background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 10px;
padding: 20px 24px; display: flex; flex-direction: column;
}
.chart-panel.donut { flex: 1.0; min-width: 240px; }
.chart-panel.bars { flex: 1.0; min-width: 240px; }
.chart-panel.modules { flex: 1.8; min-width: 320px; }
.chart-panel-title { font-size: 0.78rem; font-weight: 600; color: #64748b;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 16px; }
.chart-box { flex: 1; min-height: 240px; position: relative; }
.donut-wrap { position: relative; display: flex; align-items: center; justify-content: center; }
.donut-center {
position: absolute; text-align: center; pointer-events: none;
}
.donut-center .dc-num { font-size: 2rem; font-weight: 700; color: #1e293b; line-height: 1; }
.donut-center .dc-lbl { font-size: 0.72rem; color: #94a3b8; margin-top: 3px; }
.stat-bars { display: flex; flex-direction: column; gap: 12px; justify-content: center; flex: 1; }
.stat-bar-item { display: flex; align-items: center; gap: 10px; }
.stat-bar-item .sb-label { width: 36px; font-size: 0.78rem; color: #64748b; font-weight: 500; }
.stat-bar-item .sb-label.pass { color: #059669; }
.stat-bar-item .sb-label.fail { color: #dc2626; }
.stat-bar-item .sb-label.error { color: #d97706; }
.stat-bar-item .sb-label.skip { color: #9ca3af; }
.stat-bar-item .sb-track { flex: 1; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
.stat-bar-item .sb-fill { height: 100%%; border-radius: 4px; transition: width 0.8s ease; }
.stat-bar-item .sb-fill.pass { background: #059669; }
.stat-bar-item .sb-fill.fail { background: #dc2626; }
.stat-bar-item .sb-fill.error { background: #d97706; }
.stat-bar-item .sb-fill.skip { background: #9ca3af; }
.stat-bar-item .sb-num { width: 28px; text-align: right; font-size: 0.82rem; font-weight: 700; color: #1e293b; }
.report-footer {
background: #1e293b; color: rgba(255,255,255,0.6);
padding: 16px 32px; text-align: center; border-radius: 0; font-size: 0.85rem;
}
.back-top {
position: fixed; bottom: 30px; right: 30px; width: 42px; height: 42px;
border-radius: 50%%; background: #0f3460; color: white; border: none;
display: flex; align-items: center; justify-content: center;
cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
opacity: 0; visibility: hidden; transition: all 0.3s;
}
.back-top.show { opacity: 1; visibility: visible; }
.back-top:hover { background: #16213e; transform: translateY(-3px); }
@media (max-width: 768px) {
body { padding: 0; }
.container-main { padding: 0 10px; }
.stat-cards { flex-direction: column; }
.filter-bar { justify-content: center; }
.result-table th, .result-table td { padding: 8px 6px; font-size: 0.8rem; }
.screenshot-thumb { width: 160px; height: 120px; }
}
@media print { .back-top, .filter-bar { display: none !important; } body { padding: 0; } }
</style>
</head>
<body>
<div class="container-main">
<header class="report-header">
<h1>%(title)s</h1>
<p class="sub">%(description)s</p>
</header>
<section class="card-section">
<div class="stat-cards">
<div class="stat-card total">
<div class="sc-icon"><i class="fas fa-list-check"></i></div>
<div class="sc-body">
<div class="sc-label">总用例数</div>
<div class="sc-num">%(count)s</div>
<div class="sc-pct">已执行</div>
</div>
<i class="fas fa-list-check sc-watermark"></i>
</div>
<div class="stat-card pass">
<div class="sc-icon"><i class="fas fa-circle-check"></i></div>
<div class="sc-body">
<div class="sc-label">通过</div>
<div class="sc-num">%(Pass)s</div>
<div class="sc-pct">占比 %(pass_pct)s%%</div>
</div>
<i class="fas fa-circle-check sc-watermark"></i>
</div>
<div class="stat-card fail">
<div class="sc-icon"><i class="fas fa-circle-xmark"></i></div>
<div class="sc-body">
<div class="sc-label">失败</div>
<div class="sc-num">%(fail)s</div>
<div class="sc-pct">占比 %(fail_pct)s%%</div>
</div>
<i class="fas fa-circle-xmark sc-watermark"></i>
</div>
<div class="stat-card error">
<div class="sc-icon"><i class="fas fa-triangle-exclamation"></i></div>
<div class="sc-body">
<div class="sc-label">错误</div>
<div class="sc-num">%(error)s</div>
<div class="sc-pct">占比 %(error_pct)s%%</div>
</div>
<i class="fas fa-triangle-exclamation sc-watermark"></i>
</div>
<div class="stat-card skip">
<div class="sc-icon"><i class="fas fa-forward"></i></div>
<div class="sc-body">
<div class="sc-label">跳过</div>
<div class="sc-num">%(skip)s</div>
<div class="sc-pct">占比 %(skip_pct)s%%</div>
</div>
<i class="fas fa-forward sc-watermark"></i>
</div>
</div>
<div class="progress-wrap">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;align-items:center">
<span style="font-size:0.82rem;color:#64748b;font-weight:500">
<i class="fas fa-gauge-high" style="margin-right:5px;color:#10b981"></i>通过率
</span>
<span style="font-size:1rem;font-weight:700;color:#059669">%(pass_rate)s%%</span>
</div>
<div class="progress">
<div class="progress-bar" style="width:%(pass_rate)s%%;background:#10b981;"></div>
</div>
</div>
<div class="meta-grid">%(attributes)s</div> </section>
<section class="card-section">
<div class="charts-row">
<div class="chart-panel donut">
<div class="chart-panel-title"><i class="fas fa-chart-pie" style="margin-right:5px"></i>结果分布</div>
<div class="donut-wrap chart-box">
<canvas id="pieChart"></canvas>
<div class="donut-center">
<div class="dc-num">%(pass_rate)s%%</div>
<div class="dc-lbl">通过率</div>
</div>
</div>
</div>
<div class="chart-panel bars">
<div class="chart-panel-title"><i class="fas fa-bars" style="margin-right:5px"></i>分项统计</div>
<div class="stat-bars">
<div class="stat-bar-item">
<span class="sb-label pass">通过</span>
<div class="sb-track"><div class="sb-fill pass" id="bar_pass" style="width:0%%"></div></div>
<span class="sb-num">%(Pass)s</span>
</div>
<div class="stat-bar-item">
<span class="sb-label fail">失败</span>
<div class="sb-track"><div class="sb-fill fail" id="bar_fail" style="width:0%%"></div></div>
<span class="sb-num">%(fail)s</span>
</div>
<div class="stat-bar-item">
<span class="sb-label error">错误</span>
<div class="sb-track"><div class="sb-fill error" id="bar_error" style="width:0%%"></div></div>
<span class="sb-num">%(error)s</span>
</div>
<div class="stat-bar-item">
<span class="sb-label skip">跳过</span>
<div class="sb-track"><div class="sb-fill skip" id="bar_skip" style="width:0%%"></div></div>
<span class="sb-num">%(skip)s</span>
</div>
</div>
</div>
<div class="chart-panel modules">
<div class="chart-panel-title"><i class="fas fa-layer-group" style="margin-right:5px"></i>模块分布</div>
<div class="chart-box"><canvas id="barChart"></canvas></div>
</div>
</div>
</section>
<section class="card-section">
<div class="filter-bar">
<span style="font-weight:600;margin-right:4px"><i class="fas fa-filter"></i> 筛选:</span>
<button class="filter-btn active all" onclick="showCase(0)">概览</button>
<button class="filter-btn fail" onclick="showCase(1)">仅失败</button>
<button class="filter-btn error" onclick="showCase(2)">仅错误</button>
<button class="filter-btn pass" onclick="showCase(3)">全部</button>
</div>
<div class="table-responsive">
<table class="result-table">
<thead>
<tr>
<th>测试类 / 用例</th>
<th style="width:80px">总数</th>
<th style="width:80px">通过</th>
<th style="width:80px">失败</th>
<th style="width:80px">错误</th>
<th style="width:90px">操作</th>
</tr>
</thead>
<tbody>
%(test_list)s
</tbody>
<tfoot>
<tr style="background:#343a40;color:white;font-weight:700">
<td>合计</td>
<td>%(count)s</td>
<td>%(Pass)s</td>
<td>%(fail)s</td>
<td>%(error)s</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</section>
<footer class="report-footer">© %(generate_time)s 自动化测试团队</footer>
</div>
<div class="lightbox-overlay" id="lightboxOverlay" onclick="closeLightbox()">
<img id="lightboxImg" src="" alt="截图预览" />
</div>
<div class="copy-toast" id="copyToast"><i class="fas fa-check"></i> 已复制到剪贴板</div>
<button class="back-top" id="backTop" onclick="window.scrollTo({top:0,behavior:'smooth'})">
<i class="fas fa-arrow-up"></i>
</button>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var total = %(count)s || 1;
// 分项统计条动画
var bars = {pass: %(Pass)s, fail: %(fail)s, error: %(error)s, skip: %(skip)s};
setTimeout(function() {
Object.keys(bars).forEach(function(k) {
var el = document.getElementById('bar_' + k);
if (el) el.style.width = (bars[k] / total * 100).toFixed(1) + '%%';
});
}, 100);
// 结果分布(甜甜圈)
new Chart(document.getElementById('pieChart').getContext('2d'), {
type: 'doughnut',
data: {
labels: ['通过','失败','错误','跳过'],
datasets: [{ data: [%(Pass)s,%(fail)s,%(error)s,%(skip)s],
backgroundColor: ['#059669','#ef4444','#f59e0b','#9ca3af'],
borderWidth: 2, borderColor: '#f8fafc',
hoverOffset: 6 }]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '68%%',
plugins: {
legend: { display: false },
tooltip: { callbacks: {
label: function(c) {
return ' ' + c.label + ': ' + c.parsed +
' (' + (c.parsed / total * 100).toFixed(1) + '%%)';
}
}}
}
}
});
// 模块分布(横向柱)
new Chart(document.getElementById('barChart').getContext('2d'), {
type: 'bar',
data: {
labels: %(chart_labels)s,
datasets: [{ label:'用例数', data: %(chart_data)s,
backgroundColor: '#334155', borderRadius: 4,
barThickness: 'flex', maxBarThickness: 24 }]
},
options: {
indexAxis: 'y',
responsive: true, maintainAspectRatio: false,
layout: { padding: { left: 4, right: 12 } },
scales: {
x: { beginAtZero: true, ticks: { stepSize: 1 }, grid: { color: '#f1f5f9' } },
y: {
grid: { display: false },
ticks: {
autoSkip: false,
font: { size: 11 },
color: '#475569',
// 长名称按 _ 智能换行:每行最多 18 字符,超长的拆成多行(Chart.js 原生支持数组形式 label)
callback: function(value) {
var lbl = this.getLabelForValue(value);
if (!lbl || lbl.length <= 22) return lbl;
var parts = lbl.split('_');
var lines = [], cur = '';
for (var i = 0; i < parts.length; i++) {
var seg = (cur ? cur + '_' : '') + parts[i];
if (seg.length > 22 && cur) {
lines.push(cur);
cur = parts[i];
} else {
cur = seg;
}
}
if (cur) lines.push(cur);
return lines;
}
}
}
},
plugins: {
legend: { display: false },
tooltip: { callbacks: {
title: function(items) { return items[0].label.replace(/,/g, '_'); }
}}
}
}
});
var bt = document.getElementById('backTop');
window.addEventListener('scroll', function() { bt.classList.toggle('show', window.pageYOffset > 300); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeLightbox(); });
// 应用初始筛选状态,与默认 active 的"概览"按钮保持一致
showCase(0);
});
// 0=概览(折叠所有class,只看汇总) 1=仅失败 2=仅错误 3=全部
var currentLevel = 0;
function showCase(level) {
currentLevel = level;
document.querySelectorAll('.filter-btn').forEach(function(b){ b.classList.remove('active'); });
document.querySelectorAll('.filter-btn')[level].classList.add('active');
// 先收起所有已展开的 detail 行,并重置 data-open 状态
document.querySelectorAll('.result-table tbody tr').forEach(function(tr) {
var id = tr.id || '';
if (id.indexOf('detail_') === 0) {
tr.style.display = 'none';
tr.dataset.open = '0';
var mainRow = document.getElementById(id.replace('detail_', ''));
if (mainRow) {
var btn = mainRow.querySelector('.detail-toggle');
if (btn) btn.classList.remove('expanded');
}
}
});
var classRows = document.querySelectorAll('.result-table tbody tr.class-row');
for (var ci = 0; ci < classRows.length; ci++) {
var classRow = classRows[ci];
var cid = classRow.id;
var num = parseInt(classRow.querySelector('td').getAttribute('onclick').match(/,(\d+)\)/)[1]);
// 概览:折叠所有用例行,只显示 class 汇总行
if (level === 0) {
classRow.style.display = '';
for (var ti = 1; ti <= num; ti++) {
var base0 = 't' + cid.substr(1) + '.' + ti;
var r0 = document.getElementById('ff' + base0) ||
document.getElementById('fe' + base0) ||
document.getElementById('p' + base0);
if (r0) r0.style.display = 'none';
}
continue;
}
// 其他模式:按条件过滤用例行
var hasVisible = false;
for (var ti = 1; ti <= num; ti++) {
var base = 't' + cid.substr(1) + '.' + ti;
var testRow = document.getElementById('ff' + base) ||
document.getElementById('fe' + base) ||
document.getElementById('p' + base);
if (!testRow) continue;
var isFail = testRow.id.indexOf('ff') === 0;
var isError = testRow.id.indexOf('fe') === 0;
var show;
if (level === 1) { show = isFail; }
else if (level === 2) { show = isError; }
else { show = true; } // level === 3 全部
testRow.style.display = show ? '' : 'none';
if (show) hasVisible = true;
}
// 仅失败/仅错误时,若该 class 下无可见用例则隐藏 class 行
classRow.style.display = (level !== 3 && !hasVisible) ? 'none' : '';
}
}
function showClassDetail(cid, count) {
var childRows = [];
var anyVisible = false;
for (var i = 0; i < count; i++) {
var base = 't' + cid.substr(1) + '.' + (i + 1);
var row = document.getElementById('ff' + base) ||
document.getElementById('fe' + base) ||
document.getElementById('p' + base);
if (row) {
childRows.push(row);
if (row.style.display !== 'none') anyVisible = true;
}
}
for (var j = 0; j < childRows.length; j++) {
var row = childRows[j];
if (anyVisible) {
// 折叠:全部隐藏,收起 detail,重置 data-open
row.style.display = 'none';
var detailRow = document.getElementById('detail_' + row.id);
if (detailRow) {
detailRow.style.display = 'none';
detailRow.dataset.open = '0';
var btn = row.querySelector('.detail-toggle');
if (btn) btn.classList.remove('expanded');
}
} else {
// 展开:遵守当前筛选级别
var isFail2 = row.id.indexOf('ff') === 0;
var isError2 = row.id.indexOf('fe') === 0;
var show2;
if (currentLevel === 0) { show2 = true; }
else if (currentLevel === 1) { show2 = isFail2; }
else if (currentLevel === 2) { show2 = isError2; }
else { show2 = true; }
row.style.display = show2 ? '' : 'none';
}
}
}
function toggleDetail(btn, tid) {
var detailRow = document.getElementById('detail_' + tid);
if (!detailRow) return;
// 用 dataset 标记展开状态,避免 display 空字符串歧义
var isOpen = detailRow.dataset.open === '1';
if (isOpen) {
detailRow.style.display = 'none';
detailRow.dataset.open = '0';
btn.classList.remove('expanded');
} else {
detailRow.style.display = 'table-row';
detailRow.dataset.open = '1';
btn.classList.add('expanded');
switchDetailTab(tid, 'output');
}
}
function switchDetailTab(tid, tabName) {
var container = document.getElementById('detail_' + tid);
if (!container) return;
container.querySelectorAll('.inline-tab-btn').forEach(function(b) { b.classList.remove('active'); });
container.querySelectorAll('.inline-tab-content').forEach(function(c) { c.classList.remove('active'); });
var activeBtn = container.querySelector('.inline-tab-btn.' + tabName);
var activeContent = container.querySelector('.inline-tab-content.' + tabName);
if (activeBtn) activeBtn.classList.add('active');
if (activeContent) activeContent.classList.add('active');
}
function openLightbox(imgSrc) {
var overlay = document.getElementById('lightboxOverlay');
var img = document.getElementById('lightboxImg');
img.src = imgSrc;
overlay.classList.add('active');
}
function closeLightbox() {
var overlay = document.getElementById('lightboxOverlay');
overlay.classList.remove('active');
document.getElementById('lightboxImg').src = '';
}
function copyPre(btn) {
var pre = btn.parentNode.querySelector('pre');
if (!pre) return;
var text = pre.textContent || pre.innerText || '';
var done = function() {
var span = btn.querySelector('span');
var icon = btn.querySelector('i');
var oldTxt = span ? span.textContent : '';
btn.classList.add('copied');
if (icon) icon.className = 'fas fa-check';
if (span) span.textContent = '已复制';
showCopyToast();
setTimeout(function() {
btn.classList.remove('copied');
if (icon) icon.className = 'far fa-copy';
if (span) span.textContent = oldTxt || '复制';
}, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done).catch(function() {
fallbackCopy(text, done);
});
} else {
fallbackCopy(text, done);
}
}
function fallbackCopy(text, cb) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.top = '-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); cb && cb(); } catch (e) {}
document.body.removeChild(ta);
}
function showCopyToast() {
var t = document.getElementById('copyToast');
if (!t) return;
t.classList.add('show');
clearTimeout(t._timer);
t._timer = setTimeout(function() { t.classList.remove('show'); }, 1500);
}
</script>
</body>
</html>"""
REPORT_CLASS_TMPL = r"""
<tr class="class-row" id="%(cid)s">
<td colspan="6" onclick="showClassDetail('%(cid)s',%(count)s)">
<i class="fas fa-folder-open" style="margin-right:6px;color:#667eea"></i>%(desc)s
<span style="font-weight:400;font-size:0.85rem;color:#888;margin-left:8px">%(count)s 个用例</span>
</td>
</tr>"""
REPORT_TEST_WITH_OUTPUT_TMPL = r"""
<tr id="%(tid)s" class="%(row_class)s">
<td style="padding-left:36px">%(desc)s</td>
<td colspan="5">
<span class="status-tag %(tag_class)s">%(status_text)s</span>
<span style="margin-left:8px;font-size:0.8rem;color:#888"><i class="fas fa-clock"></i> %(elapsed)s</span>
<button class="detail-toggle" onclick="toggleDetail(this,'%(tid)s')">
<i class="fas fa-chevron-down"></i> 详情
</button>
</td>
</tr>
<tr id="detail_%(tid)s" class="inline-detail-row" style="display:none" data-open="0">
<td colspan="6">
<div class="inline-detail-container">
<div class="inline-tab-bar">
<button class="inline-tab-btn output active" onclick="switchDetailTab('%(tid)s','output')">运行输出</button>
<button class="inline-tab-btn traceback" onclick="switchDetailTab('%(tid)s','traceback')">错误堆栈</button>
<button class="inline-tab-btn screenshot" onclick="switchDetailTab('%(tid)s','screenshot')">截图</button>
</div>
<div class="inline-tab-content output active" id="tab_output_%(tid)s"><div class="pre-wrap"><button class="copy-btn" onclick="copyPre(this)"><i class="far fa-copy"></i><span>复制</span></button><pre class="inline-pre">%(process)s</pre></div></div>
<div class="inline-tab-content traceback" id="tab_traceback_%(tid)s"><div class="pre-wrap"><button class="copy-btn" onclick="copyPre(this)"><i class="far fa-copy"></i><span>复制</span></button><pre class="inline-pre">%(script)s</pre></div></div>
<div class="inline-tab-content screenshot" id="tab_screenshot_%(tid)s">%(screenshots)s</div>
</div>
</td>
</tr>"""
REPORT_TEST_NO_OUTPUT_TMPL = r"""
<tr id="%(tid)s" class="%(row_class)s">
<td style="padding-left:36px">%(desc)s</td>
<td colspan="5">
<span class="status-tag %(tag_class)s">%(status_text)s</span>
<span style="margin-left:8px;font-size:0.8rem;color:#888"><i class="fas fa-clock"></i> %(elapsed)s</span>
</td>
</tr>"""
ENDING_TMPL = ""
class _TestResult(unittest.TestResult):
def __init__(self, verbosity=1):
super().__init__(stream=None, descriptions=None, verbosity=verbosity)
self.success_case = []
self.failure_case = []
self.error_case = []
self.success_count = 0
self.failure_count = 0
self.error_count = 0
self.skip_count = 0
self.verbosity = verbosity
self.result = []
self.outputBuffer = None
self.stdout0 = None
self.stderr0 = None
self._log_handler = None
# 重试覆盖支持:同一 test.id() 重复回调时覆盖之前的记录
self._test_id_to_idx = {} # test.id() -> result 列表索引
self._last_status = {} # test.id() -> 上次状态码 (0/1/2/3)
@staticmethod
def _test_key(test):
try:
return test.id()
except Exception:
return str(id(test))
def _record(self, status, test, output, script, elapsed):
"""统一记录入口:支持重试覆盖
status: 0=pass, 1=fail, 2=error, 3=skip
"""
key = self._test_key(test)
# 已有记录:扣减旧计数
if key in self._last_status:
old = self._last_status[key]
if old == 0: self.success_count = max(0, self.success_count - 1)
elif old == 1: self.failure_count = max(0, self.failure_count - 1)
elif old == 2: self.error_count = max(0, self.error_count - 1)
elif old == 3: self.skip_count = max(0, self.skip_count - 1)
# 同步从案例列表里移除(仅一次)
for case_list in (self.success_case, self.failure_case, self.error_case):
if test in case_list:
try: case_list.remove(test)
except ValueError: pass
# 累加新计数
if status == 0:
self.success_count += 1
self.success_case.append(test)
elif status == 1:
self.failure_count += 1
self.failure_case.append(test)
elif status == 2:
self.error_count += 1
self.error_case.append(test)
elif status == 3:
self.skip_count += 1
self._last_status[key] = status
entry = (status, test, output, script, elapsed)
if key in self._test_id_to_idx:
# 重试:覆盖旧记录
self.result[self._test_id_to_idx[key]] = entry
else:
self._test_id_to_idx[key] = len(self.result)
self.result.append(entry)
def startTest(self, test):
super().startTest(test)
self._start_time = time.time()
self.outputBuffer = io.StringIO()
stdout_redirector.fp = self.outputBuffer
stderr_redirector.fp = self.outputBuffer
self.stdout0 = sys.stdout
self.stderr0 = sys.stderr
sys.stdout = stdout_redirector
sys.stderr = stderr_redirector
if Text:
self._log_handler = logging.StreamHandler(self.outputBuffer)
self._log_handler.setLevel(logging.DEBUG)
fmt = logging.Formatter('[ %(asctime)s - %(levelname)-5s ] %(message)s')
self._log_handler.setFormatter(fmt)
Text.logger.addHandler(self._log_handler)
def _get_output(self):
if self.stdout0:
sys.stdout = self.stdout0
sys.stderr = self.stderr0
self.stdout0 = None
self.stderr0 = None
if self._log_handler and Text:
Text.logger.removeHandler(self._log_handler)
self._log_handler = None
if self.outputBuffer:
return self.outputBuffer.getvalue()
return ''
def stopTest(self, test):
if self.stdout0:
sys.stdout = self.stdout0
sys.stderr = self.stderr0
self.stdout0 = None
self.stderr0 = None
if self._log_handler and Text:
Text.logger.removeHandler(self._log_handler)
self._log_handler = None
self._elapsed = time.time() - getattr(self, '_start_time', time.time())
super().stopTest(test)
def _capture_failure_screenshot(self, test):
"""
失败/错误时主动截图当前页面,无侵入测试代码。
- 优先使用 test 实例的 driver/_driver/browser 属性
- 优先调用 saveScreenshot(项目封装),其次 save_screenshot(标准 selenium)
- 任何异常静默吞掉,不影响测试结果
- 截图日志写入 outputBuffer,会被现有 _extract_screenshots 自动抓取
"""
try:
drv = (getattr(test, 'driver', None)
or getattr(test, '_driver', None)
or getattr(test, 'browser', None))
if drv is None:
return
ts = time.strftime('%Y%m%d%H%M%S') + '_%03d' % int((time.time() % 1) * 1000)
test_name = test.id().split('.')[-1] if hasattr(test, 'id') else 'failure'
safe_name = re.sub(r'[^\w\-]', '_', test_name)[:40]
rel_path = 'Report/Screenshots/Failure_%s_%s.png' % (safe_name, ts)
# 确保目录存在(基于测试运行时的当前工作目录)
try:
os.makedirs(os.path.dirname(rel_path), exist_ok=True)
except Exception:
pass
ok = False
for fn_name in ('saveScreenshot', 'save_screenshot'):
fn = getattr(drv, fn_name, None)
if callable(fn):
try:
fn(rel_path)
ok = True
break
except Exception:
continue
if ok:
# 写入与现有日志一致的格式,能被 _extract_screenshots 自动识别
msg = '[失败时刻] 屏幕截图成功, 存放位置 %s' % rel_path
if Text:
Text.error(msg)
else:
sys.stdout.write(msg + '\n')
except Exception:
# 任何异常都不能影响测试本身
pass
def addSuccess(self, test):
super().addSuccess(test)
output = self._get_output()
elapsed = time.time() - getattr(self, '_start_time', time.time())
self._record(0, test, output, '', elapsed)
if self.verbosity > 1:
sys.stderr.write('ok ')
sys.stderr.write(str(test))
sys.stderr.write('\n')
else:
if Text:
Text.info('Result : Success.')
def addError(self, test, err):
super().addError(test, err)
_, exc_str = self.errors[-1]
if Text:
Text.error('错误堆栈:\n%s' % exc_str)
# 主动截取失败时刻的 UI 截图(无侵入)
self._capture_failure_screenshot(test)
output = self._get_output()
elapsed = time.time() - getattr(self, '_start_time', time.time())
self._record(2, test, output, exc_str, elapsed)
if self.verbosity > 1:
sys.stderr.write('E ')
sys.stderr.write(str(test))
sys.stderr.write('\n')
else:
if Text:
Text.info('Result : Error.')
def addFailure(self, test, err):
super().addFailure(test, err)
_, exc_str = self.failures[-1]
if Text:
Text.error('失败原因:\n%s' % exc_str)
# 主动截取失败时刻的 UI 截图(无侵入)
self._capture_failure_screenshot(test)
output = self._get_output()
elapsed = time.time() - getattr(self, '_start_time', time.time())
self._record(1, test, output, exc_str, elapsed)
if self.verbosity > 1:
sys.stderr.write('F ')
sys.stderr.write(str(test))
sys.stderr.write('\n')
else:
if Text:
Text.info('Result : Failure.')
def addSkip(self, test, reason=''):
super().addSkip(test, reason)
output = self._get_output()
elapsed = time.time() - getattr(self, '_start_time', time.time())
self._record(3, test, output, '', elapsed)
if self.verbosity > 1:
sys.stderr.write('skiped ')
sys.stderr.write(str(test))
sys.stderr.write('\n')
else:
if Text:
Text.info('Result : Skiped.')
class HTMLTestRunner(Template_mixin):
def __init__(self, stream=sys.stdout, verbosity=1, title=None, description=None,
screenshot_base_path=None, embed_screenshots=True, report_dir=None):
"""
:param embed_screenshots:
True(默认)= 截图 base64 内嵌进 HTML,单文件可独立分享。
注意:100 个失败用例 × 5 张截图 ≈ 200MB,浏览器可能打不开。
False = 截图以相对链接引用,HTML 体积极小(KB 级),适合大规模执行。
报告 HTML 必须与 Report/Screenshots 目录一起分发。
:param report_dir:
外链模式下 HTML 文件所在目录的绝对路径,用于计算截图相对路径。
默认 None 时假设 HTML 在 cwd(项目根);如 Report/Reports/Report.html
则需传 os.path.join(os.getcwd(), 'Report/Reports')。
"""
self.stream = stream
self.verbosity = verbosity
self.title = title or self.DEFAULT_TITLE
self.description = description or self.DEFAULT_DESCRIPTION
self.screenshot_base_path = screenshot_base_path or os.getcwd()
self.embed_screenshots = embed_screenshots
self.report_dir = report_dir
self.startTime = datetime.datetime.now()
def run(self, test):
result = _TestResult(self.verbosity)
test(result)
self.stopTime = datetime.datetime.now()
self.generateReport(test, result)
return result
def sortResult(self, result_list):
rmap = {}
classes = []
for n, t, o, e, elapsed in result_list:
cls = t.__class__
if cls not in rmap:
rmap[cls] = []
classes.append(cls)
rmap[cls].append((n, t, o, e, elapsed))
return [(cls, rmap[cls]) for cls in classes]
def generateReport(self, test, result):
total = result.success_count + result.failure_count + result.error_count + result.skip_count
effective_total = total - result.skip_count
pass_rate = round(result.success_count * 100.0 / effective_total, 1) if effective_total > 0 else 0
# 各分项相对总用例数的占比
def _pct(n):
return ('%.1f' % (n * 100.0 / total)) if total > 0 else '0.0'
pass_pct = _pct(result.success_count)
fail_pct = _pct(result.failure_count)
error_pct = _pct(result.error_count)
skip_pct = _pct(result.skip_count)
duration = str(self.stopTime - self.startTime)
attrs = [
('far fa-clock', '开始时间', str(self.startTime.strftime('%Y-%m-%d %H:%M:%S'))),
('fas fa-stopwatch', '耗时', duration.split('.')[0]),
('fas fa-list-check', '状态', self._status_text(result)),
('fas fa-percent', '通过率', str(pass_rate) + '%'),
('fab fa-python', 'Python', platform.python_version()),
('fas fa-desktop', '平台', '%s %s' % (platform.system(), platform.release())),
('fas fa-server', '主机', platform.node()),
]
attr_html = ''
for icon, key, value in attrs:
attr_html += (
'<div class="meta-item">'
'<i class="%s"></i>'
'<span class="mk">%s</span>'
'<span class="mv">%s</span>'
'</div>'
) % (icon, saxutils.escape(key), saxutils.escape(value))
chart_labels, chart_data = self._get_chart_data(result)
report = self._generate_report(result)
html = self.HTML_TMPL % dict(
title=saxutils.escape(self.title),
description=saxutils.escape(self.description),
count=str(total),
Pass=str(result.success_count),
fail=str(result.failure_count),
error=str(result.error_count),
skip=str(result.skip_count),
pass_pct=pass_pct,
fail_pct=fail_pct,
error_pct=error_pct,
skip_pct=skip_pct,
pass_rate=str(pass_rate),
attributes=attr_html,
chart_labels=chart_labels,
chart_data=chart_data,
generate_time=self.startTime.strftime('%Y-%m-%d %H:%M:%S'),
test_list=report,
)
self.stream.write(html)
def _status_text(self, result):
parts = ['总计 %s' % (result.success_count + result.failure_count + result.error_count + result.skip_count)]
if result.success_count:
parts.append('通过 %s' % result.success_count)
if result.failure_count:
parts.append('失败 %s' % result.failure_count)
if result.error_count:
parts.append('错误 %s' % result.error_count)
if result.skip_count:
parts.append('跳过 %s' % result.skip_count)
return ' '.join(parts)
def _generate_report(self, result):
rows = []
sorted_result = self.sortResult(result.result)
for cid, (cls, cls_results) in enumerate(sorted_result):
np = nf = ne = ns = 0
for n, t, o, e, elapsed in cls_results:
if n == 0:
np += 1
elif n == 1:
nf += 1
elif n == 2:
ne += 1
elif n == 3:
ns += 1
if cls.__module__ == '__main__':
name = cls.__name__
else:
name = cls.__module__
doc = cls.__doc__ and cls.__doc__.split('\n')[0].strip() or ''
desc = doc and '%s: %s' % (name, doc) or name
rows.append(self.REPORT_CLASS_TMPL % dict(
desc=saxutils.escape(desc),
count=np + nf + ne + ns,
cid='c%s' % (cid + 1),
))
for tid, (n, t, o, e, elapsed) in enumerate(cls_results):
self._generate_report_test(rows, cid, tid, n, t, o, e, elapsed)
return ''.join(rows)
def _generate_report_test(self, rows, cid, tid, n, t, o, e, elapsed=0):
# ff=失败 fe=错误 p=通过/跳过
prefix = {0: 'p', 1: 'ff', 2: 'fe', 3: 'p'}.get(n, 'p')
tid_str = '%st%s.%s' % (prefix, cid + 1, tid + 1)
name = t.id().split('.')[-1]
doc = t.shortDescription() or ''
desc = doc and '%s: %s' % (name, doc) or name
elapsed_str = '%.2fs' % elapsed
status_map = {
0: ('pass-strong', '通过', 'row-pass'),
1: ('fail', '失败', 'row-fail'),
2: ('error', '错误', 'row-error'),
3: ('skip', '跳过', ''),
}
tag_class, status_text, row_class = status_map.get(n, ('fail', '未知', ''))
# 值直接代入,不加 %% 转义(值不经过二次 % 格式化)
script = saxutils.escape(e) if e else '(无)'
process = saxutils.escape(o) if o else '(无输出)'
if n in (1, 2):
screenshots_html = self._extract_screenshots(o)
else:
screenshots_html = '<p style="color:#999;font-size:0.85rem;padding:10px 0;">(无截图)</p>'
row = self.REPORT_TEST_WITH_OUTPUT_TMPL % dict(
tid=tid_str,
row_class=row_class,
tag_class=tag_class,
desc=saxutils.escape(desc),
status_text=status_text,
elapsed=elapsed_str,
process=process,
script=script,
screenshots=screenshots_html,
)
rows.append(row)
def _extract_screenshots(self, output):
if not output:
return '<p style="color:#999;font-size:0.85rem;padding:10px 0;">(无截图)</p>'
# 同时识别普通截图和失败时刻截图(带 [失败时刻] 标记的优先标红展示)
pattern = r'(\[失败时刻\]\s*)?屏幕截图成功, 存放位置\s+(.+\.(?:png|jpg|jpeg|gif|bmp))'
matches = re.findall(pattern, output)
if not matches:
return '<p style="color:#999;font-size:0.85rem;padding:10px 0;">(无截图)</p>'
# 按出现顺序去重(同一路径只保留首次出现,并合并失败标记)
seen = {}
ordered = []
for tag, path in matches:
path = path.strip()
is_failure = bool(tag)
if path not in seen:
seen[path] = is_failure
ordered.append(path)
else:
# 已存在的若被标记为失败时刻,保留这个标记
if is_failure:
seen[path] = True
# 倒序展示:最新的(含失败时刻)排在最前
ordered_display = list(reversed(ordered))
imgs = ['<div class="screenshot-grid">']
total = len(ordered_display)
for idx, path in enumerate(ordered_display):
is_failure = seen.get(path, False)
# 倒序后第 1 张是最新的;编号按时间顺序(即原 ordered 里的位置 + 1)
seq = total - idx
badge_text = '失败时刻' if is_failure else ('#%d' % seq)
cls_extra = ' failure-shot' if is_failure else ''
resolved = path
if not os.path.isabs(path):
resolved = os.path.join(self.screenshot_base_path, path)
if not os.path.isfile(resolved):
imgs.append('<div class="screenshot-item%s" style="padding:10px;color:#dc2626;font-size:0.85rem">'
'<div class="shot-badge">%s</div>'
'⚠ 截图文件不存在: %s</div>'
% (cls_extra, badge_text, saxutils.escape(path)))
continue
try:
if self.embed_screenshots:
# base64 内嵌:单文件分享,但大量截图会让 HTML 巨大
with open(resolved, 'rb') as f:
data = base64.b64encode(f.read()).decode('ascii')
ext = os.path.splitext(path)[1].lstrip('.').lower()
mime = {'png': 'png', 'jpg': 'jpeg', 'jpeg': 'jpeg',
'gif': 'gif', 'bmp': 'bmp'}.get(ext, 'png')
img_src = 'data:image/%s;base64,%s' % (mime, data)
else:
# 外链模式:从 HTML 所在目录计算到截图的相对路径
if self.report_dir:
try:
rel = os.path.relpath(resolved, self.report_dir)
except ValueError:
rel = path # 跨盘符等异常情况兜底
else:
rel = path
img_src = saxutils.escape(rel.replace(os.sep, '/'))
imgs.append(
'<div class="screenshot-item%s">'
'<div class="shot-badge">%s</div>'
'<img class="screenshot-thumb" src="%s" loading="lazy" onclick="openLightbox(this.src)" title="点击查看原图" />'
'<div class="screenshot-name">%s</div>'
'</div>' % (cls_extra, badge_text, img_src,
saxutils.escape(os.path.basename(path)))
)
except Exception as exc:
imgs.append('<div class="screenshot-item%s" style="padding:10px;color:#dc2626;font-size:0.85rem">'
'<div class="shot-badge">%s</div>'
'⚠ 读取截图失败 (%s): %s</div>'
% (cls_extra, badge_text,
saxutils.escape(path), saxutils.escape(str(exc))))
imgs.append('</div>')
return ''.join(imgs)
def _get_chart_data(self, result):
modules = {}
for n, t, o, e, elapsed in result.result:
mod = t.__class__.__module__
modules[mod] = modules.get(mod, 0) + 1
sorted_mods = sorted(modules.items(), key=lambda x: x[1], reverse=True)[:5]
labels = [m[0].split('.')[-1] for m in sorted_mods] or ['默认']
data = [m[1] for m in sorted_mods] or [0]
return json.dumps(labels, ensure_ascii=False), json.dumps(data)
class TestProgram(unittest.TestProgram):
def runTests(self):
if self.testRunner is None:
self.testRunner = HTMLTestRunner(verbosity=self.verbosity)
unittest.TestProgram.runTests(self)
main = TestProgram
if __name__ == '__main__':
main(module=None)









