W1-W3: 基线修复与测试基础设施搭建 - 修复 Jenkins JDK 21 兼容性,统一 E2E 目录,修复 storageState 冲突 - 搭建后端测试基类 BaseContractTest + Testcontainers PostgreSQL - 创建 TestDataFactory 链式构造,完善 Vitest 基座与 Playwright fixtures - 建立 docker-compose.test.yml 与测试数据隔离方案 W4-W5: 单元测试补齐(阶段 2) - 补齐 gym-member/gym-groupCourse/gym-checkIn/gym-payment 核心模块单元测试 - 补齐 gym-coach/manage-sys 模块单元测试 - 前端 utils/composables/stores 单元测试,37 文件 502 项测试 - JaCoCo 覆盖率门禁从 30% 调整至 55%,21 模块全部通过 W6-W7: 集成与契约测试(阶段 3) - Repository 集成测试:会员/团课/签到/支付关键表,Testcontainers 100% 通过 - Handler 集成测试:WebTestClient 覆盖正向/异常/权限路径 - 网关集成测试:JWT/RBAC/签名/限流/重试 - Flyway 迁移测试:验证迁移脚本可重复执行 - OpenAPI 契约测试:覆盖 ≥80% P0 接口,202 项契约测试 0 失败 - 跨模块契约测试:会员-支付-团课数据一致性 W8-W9: E2E 与用户旅程测试(阶段 4) - 管理员 Web 核心流程 E2E:用户/角色/菜单/字典/配置 - 小程序会员端核心页面 E2E:购卡/预约/签到 - 5 条 P0 用户旅程全链路自动化,60 条 journey 测试 0 失败 W10: 变异测试与质量门禁(阶段 5) - 后端 PIT 配置:pitest-maven 1.19.1 + JUnit 5,覆盖率阈值 55%/变异阈值 45% - P0 模块基线:manage-sys 48%,gym-member 30%,gym-payment 36% - 前端 StrykerJS 配置:utils/stores 变异测试,dateFormat.ts 70.83% - Jenkins 质量门禁:JaCoCo/PIT/E2E 统一检查,不达标阻断构建 W11: 持续运行与改进(阶段 6) - 测试指标收集脚本 scripts/collect-test-metrics.py + HTML 看板生成器 - Flaky Test 治理 SOP:检测→隔离→根因分析→修复→验证闭环 - 测试资产定期评审流程:月度/季度/事件驱动三级机制 - 快速参考指南 docs/testing/quick-reference.md - 累计 10 份测试文档,7 个里程碑全部达成
501 lines
18 KiB
Python
501 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
测试质量看板生成器
|
||
=====================
|
||
从 collect-test-metrics.py 收集的历史指标数据生成可视化 HTML 看板。
|
||
|
||
看板包含:
|
||
- 概览区:最新构建通过率、覆盖率、变异分数、E2E 通过率
|
||
- 趋势图:覆盖率随时间变化曲线(内联 SVG)
|
||
- 模块健康度:各模块测试通过情况
|
||
- 构建历史:最近 10 次构建摘要
|
||
|
||
用法:
|
||
python3 scripts/generate-dashboard.py [--history-dir <path>] [--output <path>]
|
||
|
||
依赖:Python 3.8+(标准库,无外部依赖)
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description="生成测试质量看板")
|
||
parser.add_argument(
|
||
"--history-dir",
|
||
default="target/test-metrics/history",
|
||
help="历史数据目录(默认: target/test-metrics/history)",
|
||
)
|
||
parser.add_argument(
|
||
"--output",
|
||
default="target/test-metrics/dashboard.html",
|
||
help="输出 HTML 文件路径(默认: target/test-metrics/dashboard.html)",
|
||
)
|
||
parser.add_argument(
|
||
"--project-dir",
|
||
default=".",
|
||
help="项目根目录(默认: 当前目录)",
|
||
)
|
||
return parser.parse_args()
|
||
|
||
|
||
def load_history(history_dir: str) -> List[Dict[str, Any]]:
|
||
"""加载历史指标数据"""
|
||
history_file = os.path.join(history_dir, "metrics-history.json")
|
||
if not os.path.exists(history_file):
|
||
print(f"⚠️ 历史数据文件不存在: {history_file}")
|
||
return []
|
||
try:
|
||
with open(history_file, "r") as f:
|
||
return json.load(f)
|
||
except (json.JSONDecodeError, IOError) as e:
|
||
print(f"❌ 无法解析历史数据: {e}")
|
||
return []
|
||
|
||
|
||
def safe_get(d: Dict[str, Any], *keys: str, default: Any = "N/A") -> Any:
|
||
"""安全地从嵌套字典取值"""
|
||
current = d
|
||
for key in keys:
|
||
if isinstance(current, dict):
|
||
current = current.get(key, {})
|
||
else:
|
||
return default
|
||
if isinstance(current, dict) and not current:
|
||
return default
|
||
return current if current is not None else default
|
||
|
||
|
||
def generate_svg_trend(
|
||
history: List[Dict[str, Any]],
|
||
data_keys: List[str],
|
||
labels: List[str],
|
||
colors: List[str],
|
||
width: int = 800,
|
||
height: int = 300,
|
||
) -> str:
|
||
"""
|
||
生成内联 SVG 趋势图。
|
||
|
||
使用纯 SVG 折线图,无需外部依赖。
|
||
"""
|
||
if len(history) < 2:
|
||
return '<div class="no-data">数据不足,无法生成趋势图(需要至少 2 个数据点)</div>'
|
||
|
||
# 提取数据序列
|
||
series: List[List[Optional[float]]] = [[] for _ in data_keys]
|
||
for entry in history:
|
||
for i, keys in enumerate(data_keys):
|
||
val = safe_get(entry, *keys.split("."), default=None)
|
||
try:
|
||
series[i].append(float(val) if val is not None else None)
|
||
except (ValueError, TypeError):
|
||
series[i].append(None)
|
||
|
||
# 只保留至少有一个非空值的索引
|
||
valid_indices = set(range(len(history)))
|
||
for s in series:
|
||
valid_indices = {i for i in valid_indices if i < len(s) and s[i] is not None}
|
||
|
||
if len(valid_indices) < 2:
|
||
return '<div class="no-data">有效数据点不足 2 个</div>'
|
||
|
||
valid_indices = sorted(valid_indices)
|
||
n = len(valid_indices)
|
||
|
||
# 计算全局范围
|
||
all_values = []
|
||
for s in series:
|
||
for i in valid_indices:
|
||
if i < len(s) and s[i] is not None:
|
||
all_values.append(s[i])
|
||
if not all_values:
|
||
return '<div class="no-data">无有效数据</div>'
|
||
|
||
min_val = min(all_values)
|
||
max_val = max(all_values)
|
||
val_range = max_val - min_val if max_val != min_val else 10
|
||
padding = val_range * 0.1
|
||
min_val -= padding
|
||
max_val += padding
|
||
|
||
# 图表区域
|
||
margin = {"top": 20, "right": 20, "bottom": 40, "left": 60}
|
||
plot_w = width - margin["left"] - margin["right"]
|
||
plot_h = height - margin["top"] - margin["bottom"]
|
||
|
||
def x_pos(idx: int) -> float:
|
||
return margin["left"] + (idx / (n - 1)) * plot_w if n > 1 else margin["left"] + plot_w / 2
|
||
|
||
def y_pos(val: float) -> float:
|
||
return margin["top"] + plot_h - ((val - min_val) / val_range) * plot_h
|
||
|
||
# Y 轴刻度
|
||
y_ticks = 5
|
||
svg_parts = [
|
||
f'<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg" '
|
||
f'style="font-family: \'SF Mono\', Monaco, Consolas, monospace; font-size: 11px;">',
|
||
f'<rect width="{width}" height="{height}" fill="var(--bg-card)" rx="8"/>',
|
||
]
|
||
|
||
# 网格线 & Y 轴标签
|
||
for i in range(y_ticks + 1):
|
||
y = margin["top"] + (plot_h / y_ticks) * i
|
||
val = max_val - (val_range / y_ticks) * i
|
||
svg_parts.append(
|
||
f'<line x1="{margin["left"]}" y1="{y:.1f}" x2="{width - margin["right"]}" y2="{y:.1f}" '
|
||
f'stroke="var(--border)" stroke-width="1" stroke-dasharray="4,4"/>'
|
||
)
|
||
svg_parts.append(
|
||
f'<text x="{margin["left"] - 8}" y="{y:.1f + 4}" text-anchor="end" fill="var(--text-muted)">'
|
||
f"{val:.1f}%</text>"
|
||
)
|
||
|
||
# 绘制折线
|
||
for s_idx, s in enumerate(series):
|
||
points = []
|
||
for i_idx, vi in enumerate(valid_indices):
|
||
if vi < len(s) and s[vi] is not None:
|
||
points.append(f"{x_pos(i_idx):.1f},{y_pos(s[vi]):.1f}")
|
||
if len(points) >= 2:
|
||
svg_parts.append(
|
||
f'<polyline points="{" ".join(points)}" fill="none" '
|
||
f'stroke="{colors[s_idx % len(colors)]}" stroke-width="2" '
|
||
f'stroke-linejoin="round" stroke-linecap="round"/>'
|
||
)
|
||
# 数据点
|
||
for p in points:
|
||
cx, cy = p.split(",")
|
||
svg_parts.append(
|
||
f'<circle cx="{cx}" cy="{cy}" r="3" fill="{colors[s_idx % len(colors)]}" '
|
||
f'stroke="var(--bg-card)" stroke-width="1.5"/>'
|
||
)
|
||
|
||
# X 轴标签(构建号或时间)
|
||
x_step = max(1, n // 10)
|
||
for i, vi in enumerate(valid_indices):
|
||
if i % x_step == 0 or i == n - 1:
|
||
label = safe_get(history[vi], "build_number", default=str(vi))
|
||
if len(str(label)) > 8:
|
||
label = str(label)[-8:]
|
||
svg_parts.append(
|
||
f'<text x="{x_pos(i):.1f}" y="{height - 10}" text-anchor="end" '
|
||
f'transform="rotate(-30, {x_pos(i):.1f}, {height - 10})" fill="var(--text-muted)">{label}</text>'
|
||
)
|
||
|
||
# 图例
|
||
legend_x = margin["left"]
|
||
legend_y = height - margin["bottom"] + 18
|
||
for i, (label, color) in enumerate(zip(labels, colors)):
|
||
lx = legend_x + i * 160
|
||
svg_parts.append(
|
||
f'<line x1="{lx}" y1="{legend_y}" x2="{lx + 12}" y2="{legend_y}" '
|
||
f'stroke="{color}" stroke-width="2"/>'
|
||
)
|
||
svg_parts.append(
|
||
f'<text x="{lx + 16}" y="{legend_y + 4}" fill="var(--text-muted)">{label}</text>'
|
||
)
|
||
|
||
svg_parts.append("</svg>")
|
||
return "\n".join(svg_parts)
|
||
|
||
|
||
def generate_dashboard(history: List[Dict[str, Any]], output_path: str):
|
||
"""生成 HTML 看板"""
|
||
latest = history[-1] if history else {}
|
||
|
||
# 提取最新指标
|
||
jacoco_instr = safe_get(latest, "backend", "jacoco", "instruction_coverage", default="N/A")
|
||
branch_cov = safe_get(latest, "backend", "jacoco", "branch_coverage", default="N/A")
|
||
unit_pass = safe_get(latest, "backend", "unit_tests", "pass_rate", default="N/A")
|
||
unit_total = safe_get(latest, "backend", "unit_tests", "total", default="N/A")
|
||
pit_score = safe_get(latest, "pit", "overall", "mutation_coverage", default="N/A")
|
||
fe_ut_pass = safe_get(latest, "frontend", "unit_tests", "pass_rate", default="N/A")
|
||
fe_ut_total = safe_get(latest, "frontend", "unit_tests", "total", default="N/A")
|
||
fe_cov = safe_get(latest, "frontend", "coverage", "statements_coverage", default="N/A")
|
||
e2e_pass = safe_get(latest, "e2e", "pass_rate", default="N/A")
|
||
e2e_total = safe_get(latest, "e2e", "total", default="N/A")
|
||
build_number = safe_get(latest, "build_number", default="N/A")
|
||
timestamp = safe_get(latest, "timestamp", default="N/A")
|
||
|
||
# 生成趋势图
|
||
cov_trend_svg = generate_svg_trend(
|
||
history,
|
||
["backend.jacoco.instruction_coverage", "backend.jacoco.branch_coverage", "frontend.coverage.statements_coverage"],
|
||
["指令覆盖率", "分支覆盖率", "前端语句覆盖率"],
|
||
["#4CAF50", "#2196F3", "#FF9800"],
|
||
)
|
||
pit_trend_svg = generate_svg_trend(
|
||
history,
|
||
["pit.overall.mutation_coverage", "backend.unit_tests.pass_rate", "e2e.pass_rate"],
|
||
["PIT 变异覆盖率", "后端通过率", "E2E 通过率"],
|
||
["#9C27B0", "#4CAF50", "#F44336"],
|
||
)
|
||
|
||
# 构建历史表
|
||
build_rows = ""
|
||
for entry in reversed(history[-20:]):
|
||
bn = safe_get(entry, "build_number", default="N/A")
|
||
ts = safe_get(entry, "timestamp", default="")
|
||
if ts != "N/A":
|
||
try:
|
||
ts = datetime.fromisoformat(ts.replace("Z", "+00:00")).strftime("%m-%d %H:%M")
|
||
except (ValueError, AttributeError):
|
||
pass
|
||
be = safe_get(entry, "backend", "unit_tests", "pass_rate", default="-")
|
||
fe = safe_get(entry, "frontend", "unit_tests", "pass_rate", default="-")
|
||
e2e = safe_get(entry, "e2e", "pass_rate", default="-")
|
||
jc = safe_get(entry, "backend", "jacoco", "instruction_coverage", default="-")
|
||
pt = safe_get(entry, "pit", "overall", "mutation_coverage", default="-")
|
||
|
||
def cell(v, good_threshold=100):
|
||
try:
|
||
fv = float(v)
|
||
cls = "cell-good" if fv >= good_threshold else ("cell-warn" if fv >= 50 else "cell-bad")
|
||
return f'<td class="{cls}">{fv:.1f}%</td>'
|
||
except (ValueError, TypeError):
|
||
return f"<td>{v}</td>"
|
||
|
||
build_rows += (
|
||
f"<tr>"
|
||
f"<td>{bn}</td>"
|
||
f"<td>{ts}</td>"
|
||
f"{cell(be, 100)}"
|
||
f"{cell(fe, 100)}"
|
||
f"{cell(e2e, 100)}"
|
||
f"{cell(jc, 55)}"
|
||
f"{cell(pt, 45)}"
|
||
f"</tr>\n"
|
||
)
|
||
|
||
# PIT 模块详情
|
||
pit_modules = safe_get(latest, "pit", "modules", default={})
|
||
if isinstance(pit_modules, dict):
|
||
pit_rows = ""
|
||
for mod_name, mod_data in pit_modules.items():
|
||
mc = safe_get(mod_data, "mutation_coverage", default="N/A")
|
||
total = safe_get(mod_data, "total_mutations", default="N/A")
|
||
killed = safe_get(mod_data, "killed_mutations", default="N/A")
|
||
pit_rows += (
|
||
f"<tr>"
|
||
f"<td>{mod_name}</td>"
|
||
f"<td>{total}</td>"
|
||
f"<td>{killed}</td>"
|
||
f"{cell(mc, 45)}"
|
||
f"</tr>\n"
|
||
)
|
||
else:
|
||
pit_rows = "<tr><td colspan='4'>无数据</td></tr>"
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Gym Manage 测试质量看板</title>
|
||
<style>
|
||
:root {{
|
||
--bg: #f5f7fa;
|
||
--bg-card: #ffffff;
|
||
--text: #1a1a2e;
|
||
--text-muted: #6b7280;
|
||
--border: #e5e7eb;
|
||
--primary: #4f46e5;
|
||
--success: #10b981;
|
||
--warning: #f59e0b;
|
||
--danger: #ef4444;
|
||
}}
|
||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||
body {{
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
padding: 24px;
|
||
}}
|
||
.header {{
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 24px; padding: 16px 24px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border-radius: 12px; color: white;
|
||
}}
|
||
.header h1 {{ font-size: 24px; font-weight: 600; }}
|
||
.header .meta {{ font-size: 13px; opacity: 0.9; text-align: right; }}
|
||
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }}
|
||
.card {{
|
||
background: var(--bg-card); border-radius: 10px; padding: 20px;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.08); border: 1px solid var(--border);
|
||
}}
|
||
.card .label {{ font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }}
|
||
.card .value {{ font-size: 28px; font-weight: 700; }}
|
||
.card .value.good {{ color: var(--success); }}
|
||
.card .value.warn {{ color: var(--warning); }}
|
||
.card .value.bad {{ color: var(--danger); }}
|
||
.card .sub {{ font-size: 12px; color: var(--text-muted); margin-top: 4px; }}
|
||
.section {{ margin-bottom: 24px; }}
|
||
.section h2 {{ font-size: 18px; font-weight: 600; margin-bottom: 12px; color: var(--text); }}
|
||
table {{
|
||
width: 100%; border-collapse: collapse; background: var(--bg-card);
|
||
border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||
border: 1px solid var(--border);
|
||
}}
|
||
th, td {{ padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; }}
|
||
th {{ background: #f9fafb; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.3px; }}
|
||
tr:last-child td {{ border-bottom: none; }}
|
||
.cell-good {{ color: var(--success); font-weight: 600; }}
|
||
.cell-warn {{ color: var(--warning); font-weight: 600; }}
|
||
.cell-bad {{ color: var(--danger); font-weight: 600; }}
|
||
.no-data {{ padding: 40px; text-align: center; color: var(--text-muted); }}
|
||
.trend-container {{ overflow-x: auto; }}
|
||
@media (max-width: 768px) {{
|
||
body {{ padding: 12px; }}
|
||
.grid {{ grid-template-columns: repeat(2, 1fr); }}
|
||
.header {{ flex-direction: column; text-align: center; }}
|
||
.header .meta {{ text-align: center; margin-top: 8px; }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header">
|
||
<div>
|
||
<h1>🧪 Gym Manage 测试质量看板</h1>
|
||
<div style="font-size: 13px; opacity: 0.85; margin-top: 4px;">构建 #{build_number}</div>
|
||
</div>
|
||
<div class="meta">
|
||
<div>更新时间: {timestamp}</div>
|
||
<div>历史数据点: {len(history)}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="card">
|
||
<div class="label">后端指令覆盖率</div>
|
||
<div class="value {'good' if isinstance(jacoco_instr, (int, float)) and jacoco_instr >= 55 else 'warn' if isinstance(jacoco_instr, (int, float)) else ''}">{jacoco_instr if isinstance(jacoco_instr, str) else f'{jacoco_instr:.1f}%'}</div>
|
||
<div class="sub">阈值: 55%</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">后端分支覆盖率</div>
|
||
<div class="value {'good' if isinstance(branch_cov, (int, float)) and branch_cov >= 40 else 'warn' if isinstance(branch_cov, (int, float)) else ''}">{branch_cov if isinstance(branch_cov, str) else f'{branch_cov:.1f}%'}</div>
|
||
<div class="sub">阈值: 40%</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">后端单元测试通过率</div>
|
||
<div class="value {'good' if isinstance(unit_pass, (int, float)) and unit_pass >= 100 else 'warn' if isinstance(unit_pass, (int, float)) else ''}">{unit_pass if isinstance(unit_pass, str) else f'{unit_pass:.1f}%'}</div>
|
||
<div class="sub">共 {unit_total} 项</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">PIT 变异覆盖率</div>
|
||
<div class="value {'good' if isinstance(pit_score, (int, float)) and pit_score >= 45 else 'warn' if isinstance(pit_score, (int, float)) else ''}">{pit_score if isinstance(pit_score, str) else f'{pit_score:.1f}%'}</div>
|
||
<div class="sub">阈值: 45%</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">前端语句覆盖率</div>
|
||
<div class="value">{fe_cov if isinstance(fe_cov, str) else f'{fe_cov:.1f}%'}</div>
|
||
<div class="sub">阈值: 40%</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">前端单元测试通过率</div>
|
||
<div class="value {'good' if isinstance(fe_ut_pass, (int, float)) and fe_ut_pass >= 100 else 'warn' if isinstance(fe_ut_pass, (int, float)) else ''}">{fe_ut_pass if isinstance(fe_ut_pass, str) else f'{fe_ut_pass:.1f}%'}</div>
|
||
<div class="sub">共 {fe_ut_total} 项</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">E2E 测试通过率</div>
|
||
<div class="value {'good' if isinstance(e2e_pass, (int, float)) and e2e_pass >= 100 else 'bad' if isinstance(e2e_pass, (int, float)) else ''}">{e2e_pass if isinstance(e2e_pass, str) else f'{e2e_pass:.1f}%'}</div>
|
||
<div class="sub">共 {e2e_total} 项</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>📈 覆盖率趋势</h2>
|
||
<div class="card trend-container">
|
||
{cov_trend_svg}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>📈 通过率与变异分数趋势</h2>
|
||
<div class="card trend-container">
|
||
{pit_trend_svg}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>🧬 PIT 变异测试模块详情</h2>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>模块</th>
|
||
<th>总变异数</th>
|
||
<th>已杀死</th>
|
||
<th>变异覆盖率</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{pit_rows}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>🏗️ 构建历史(最近 20 次)</h2>
|
||
<div style="overflow-x: auto;">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>构建号</th>
|
||
<th>时间</th>
|
||
<th>后端通过率</th>
|
||
<th>前端通过率</th>
|
||
<th>E2E 通过率</th>
|
||
<th>指令覆盖率</th>
|
||
<th>PIT 分数</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{build_rows}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
</body>
|
||
</html>"""
|
||
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
with open(output_path, "w", encoding="utf-8") as f:
|
||
f.write(html)
|
||
print(f"✅ 看板已生成: {output_path}")
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
project_dir = os.path.abspath(args.project_dir)
|
||
history_dir = os.path.join(project_dir, args.history_dir)
|
||
output_path = os.path.join(project_dir, args.output)
|
||
|
||
print("📊 生成测试质量看板...")
|
||
history = load_history(history_dir)
|
||
if not history:
|
||
print("⚠️ 无历史数据,生成空看板")
|
||
# 生成一个初始数据点,避免空看板
|
||
history = [{
|
||
"timestamp": datetime.now().isoformat(),
|
||
"build_number": "initial",
|
||
"backend": {},
|
||
"frontend": {},
|
||
"e2e": {},
|
||
"pit": {},
|
||
}]
|
||
|
||
generate_dashboard(history, output_path)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main()) |