零碎知识 基于 unittest 的 HTML 测试报告生成器,支持失败自动截图、用例重试覆盖、详情折叠/筛选

大海 · June 22, 2026 · 241 hits
# -*- 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">&copy; %(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)










如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
No Reply at the moment.
需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up