@harmonyos-arkts/d2h 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +15 -13
- package/.claude-plugin/plugin.json +13 -7
- package/AGENTS.md +5 -5
- package/README.md +212 -212
- package/agents/android-to-hmos-00-orchestrator.md +318 -318
- package/agents/d2h.md +157 -157
- package/dist/bin/install-opencode.js +30 -30
- package/docs/environment-check.md +86 -86
- package/install-opencode.sh +160 -160
- package/opencode/agents.json +16 -16
- package/opencode/vscode-d2h-package.json +7 -7
- package/opencode/vscode-d2h-plugin.mjs +29 -29
- package/package.json +54 -54
- package/schemas/android-source-manifest.schema.json +25 -25
- package/schemas/checkpoint-provenance.schema.json +67 -67
- package/schemas/final-acceptance.schema.json +29 -29
- package/schemas/managed-evidence-index.schema.json +15 -15
- package/schemas/managed-evidence.schema.json +114 -114
- package/schemas/migration-config.schema.json +51 -51
- package/schemas/migration-report-index.schema.json +39 -39
- package/schemas/migration-report-item.schema.json +51 -51
- package/schemas/migration-report-summary.schema.json +25 -25
- package/schemas/migration-status.schema.json +202 -202
- package/schemas/preflight.schema.json +60 -60
- package/schemas/source-order-audit.schema.json +16 -16
- package/schemas/source-provenance-event.schema.json +19 -19
- package/schemas/spec-app-shard.schema.json +45 -45
- package/schemas/spec-fact-corrections-shard.schema.json +13 -13
- package/schemas/spec-features-shard.schema.json +43 -43
- package/schemas/spec-index.schema.json +26 -26
- package/schemas/spec-interactions-shard.schema.json +40 -40
- package/schemas/spec-page.schema.json +98 -98
- package/schemas/spec-pages-index.schema.json +1 -1
- package/schemas/spec-unresolved-shard.schema.json +1 -1
- package/schemas/task-envelope.schema.json +109 -109
- package/schemas/task-plan.schema.json +341 -341
- package/scripts/check-environment.mjs +320 -320
- package/skills/android2hmos_resources_convert/SKILL.md +180 -180
- package/skills/android2hmos_resources_convert/references/image-conversion-rules.md +230 -230
- package/skills/android2hmos_resources_convert/references/svg-fix-patterns.md +175 -175
- package/skills/android2hmos_resources_convert/references/xml-drawable-to-svg-rules.md +513 -513
- package/skills/arkts-app-identity/SKILL.md +238 -238
- package/skills/arkts-i18n/SKILL.md +496 -496
- package/skills/arkts-i18n/evals/evals.json +84 -84
- package/skills/arkts-i18n/references/code-examples.md +302 -302
- package/skills/arkts-i18n/references/common-pitfalls.md +391 -391
- package/skills/arkts-i18n/references/dynamic-language-switch.md +604 -604
- package/skills/arkts-i18n/references/hardcoded-string-scanner.md +348 -348
- package/skills/arkts-i18n/references/language-codes.md +104 -104
- package/skills/arkts-i18n/references/resource-file-structure.md +775 -775
- package/skills/arkts-i18n/references/static-vs-dynamic.md +242 -242
- package/skills/arkts-i18n/references/v1-compat.md +244 -244
- package/skills/arkts-i18n/scripts/audit_i18n_completeness.sh +174 -174
- package/skills/arkts-icon-sizing/SKILL.md +211 -211
- package/skills/arkts-icon-sizing/scripts/icon_audit.py +131 -131
- package/skills/arkts-icon-sizing/scripts/icon_autofix.py +88 -88
- package/skills/arkts-icon-sizing/scripts/icon_dims.py +179 -179
- package/skills/arkts-icon-sizing/scripts/icon_fix.py +119 -119
- package/skills/arkts-mvvm-architecture/SKILL.md +613 -613
- package/skills/d2h-hypium-forge/SKILL.md +309 -309
- package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -140
- package/skills/d2h-hypium-forge/references/official-api.md +135 -135
- package/skills/d2h-hypium-forge/references/pitfalls.md +98 -98
- package/skills/d2h-hypium-forge/references/testability-template.md +71 -71
- package/skills/d2h-hypium-forge/scripts/_common_template.py +659 -659
- package/skills/d2h-hypium-forge/scripts/analyze_results.py +165 -165
- package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -107
- package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -169
- package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -325
- package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -114
- package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -80
- package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -60
- package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -249
- package/skills/d2h-hypium-forge/scripts/precondition.py +140 -140
- package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -232
- package/skills/d2h-hypium-forge/scripts/probe.py +197 -197
- package/skills/d2h-hypium-forge/scripts/run_direct.py +823 -823
- package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -121
- package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -255
- package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -66
- package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -84
- package/skills/d2h-page-develop/SKILL.md +112 -112
- package/skills/d2h-page-develop/agents/openai.yaml +7 -7
- package/skills/d2h-page-spec-generate/SKILL.md +206 -206
- package/skills/d2h-page-spec-generate/scripts/build_batch_report.py +227 -227
- package/skills/d2h-utg-ai-generate/SKILL.md +128 -128
- package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -76
- package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -34
- package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -96
- package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -61
- package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -89
- package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -314
- package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -123
- package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -280
- package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -524
- package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -489
- package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -69
- package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -13
- package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -2
- package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -369
- package/skills/d2h-utg-static-generate/SKILL.md +73 -73
- package/skills/d2h-utg-static-generate/references/discoveries.md +38 -38
- package/skills/d2h-utg-static-generate/scripts/generate.py +499 -499
- package/skills/d2h-utg-static-generate/tests/test_static.py +335 -335
- package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -60
- package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -131
- package/skills/harmonyos-migration-playbook/SKILL.md +83 -83
- package/skills/harmonyos-migration-playbook/agents/openai.yaml +7 -7
- package/skills/harmonyos-migration-playbook/references/arkts-compile.md +24 -24
- package/skills/harmonyos-migration-playbook/references/harmony-runtime.md +53 -53
- package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +45 -45
- package/skills/harmonyos-migration-playbook/references/protocol-e2e.md +23 -23
- package/skills/harmonyos-migration-playbook/references/ui-automation.md +53 -53
- package/skills/harmonyos-migration-playbook/references/windows-environment.md +38 -38
- package/skills/maintaining-migration-report/SKILL.md +144 -144
- package/skills/maintaining-migration-report/references/final-acceptance.md +48 -48
- package/skills/maintaining-migration-report/references/source-provenance.md +66 -66
- package/skills/managing-d2h-toolchain/SKILL.md +24 -24
- package/skills/managing-d2h-toolchain/environment-requirements.json +16 -16
- package/skills/managing-d2h-toolchain/scripts/manage-toolchain.mjs +98 -98
- package/skills/managing-d2h-toolchain/toolchain.json +16 -16
- package/skills/native-library-substitution/SKILL.md +384 -384
- package/skills/native-library-substitution/references/native-library-substitution.json +56905 -56905
- package/skills/native-library-substitution/references/native-library-substitution.md +163 -163
- package/skills/preparing-migration-workspace/SKILL.md +131 -131
- package/skills/reviewing-migration-process/SKILL.md +62 -62
|
@@ -1,325 +1,325 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""forge_report.py — 每工程测试执行报告(HTML,参考 Allure/extent 信息架构,零依赖)。
|
|
4
|
-
|
|
5
|
-
数据源(.hypium_forge/ 下):
|
|
6
|
-
results.json 最新一轮执行结果(case/status/msg/dur)
|
|
7
|
-
cases.json 用例(含 priority/scenarios/steps/title)
|
|
8
|
-
scenarios.json 场景注册表(可选,有则出场景维度)
|
|
9
|
-
reports/<d>/<ts>/ 截图:<case>.jpeg(成功)/ <case>_fail.jpeg(失败)
|
|
10
|
-
|
|
11
|
-
产出:test-report.html
|
|
12
|
-
① 执行概况:总数/通过率/优先级分布/耗时统计/场景覆盖
|
|
13
|
-
② 用例明细卡片:状态徽标、优先级、场景、步骤、失败原因、耗时、成功&失败截图
|
|
14
|
-
③ 结果条形总览(纯 CSS,按用例序排列)
|
|
15
|
-
|
|
16
|
-
用法(在 .hypium_forge 目录下):
|
|
17
|
-
python3 forge_report.py [--run-dir reports/2026-09-27/10-04-05] # 不指定则取最新
|
|
18
|
-
"""
|
|
19
|
-
import argparse
|
|
20
|
-
import glob
|
|
21
|
-
import html
|
|
22
|
-
import json
|
|
23
|
-
import os
|
|
24
|
-
from datetime import datetime
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
EMBED = False # --embed:关键帧 base64 内嵌开关
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def thumb_b64(path, max_w=480):
|
|
31
|
-
"""sips 缩略 → base64(macOS 零依赖;失败返回 None 走原相对路径)。"""
|
|
32
|
-
import base64 as _b64
|
|
33
|
-
import subprocess as _sp
|
|
34
|
-
import tempfile as _tf
|
|
35
|
-
try:
|
|
36
|
-
with _tf.NamedTemporaryFile(suffix='.jpeg', delete=False) as tf:
|
|
37
|
-
tmp = tf.name
|
|
38
|
-
_sp.run(['sips', '-Z', str(max_w), '-s', 'formatOptions', '55', path, '--out', tmp],
|
|
39
|
-
capture_output=True, timeout=15)
|
|
40
|
-
data = open(tmp, 'rb').read()
|
|
41
|
-
import os as _os
|
|
42
|
-
_os.unlink(tmp)
|
|
43
|
-
return 'data:image/jpeg;base64,' + _b64.b64encode(data).decode()
|
|
44
|
-
except Exception:
|
|
45
|
-
return None
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def load(path, default=None):
|
|
49
|
-
if os.path.exists(path):
|
|
50
|
-
with open(path, encoding='utf-8') as f:
|
|
51
|
-
return json.load(f)
|
|
52
|
-
return default
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def find_latest_run():
|
|
56
|
-
runs = sorted(glob.glob(os.path.join('reports', '*', '*')))
|
|
57
|
-
dirs = [r for r in runs if os.path.isdir(r) and
|
|
58
|
-
(glob.glob(os.path.join(r, '*.jpeg')) or glob.glob(os.path.join(r, '*.jpg')) or
|
|
59
|
-
glob.glob(os.path.join(r, '*.png')))]
|
|
60
|
-
return dirs[-1] if dirs else None
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
__JS__ = '<script>var btns=document.querySelectorAll(".filters button");var f={st:"",pr:""};btns.forEach(function(b){b.onclick=function(){document.querySelectorAll(\'[data-f="\'+b.dataset.f+\'"]\').forEach(function(x){x.classList.remove("on")});b.classList.add("on");f[b.dataset.f]=b.dataset.v;apply();};});document.getElementById("q").oninput=apply;function apply(){var q=document.getElementById("q").value.toLowerCase();document.querySelectorAll("tr.row").forEach(function(tr){var ok=(!f.st||tr.dataset.st===f.st)&&(!f.pr||tr.dataset.pr===f.pr)&&(!q||tr.dataset.kw.indexOf(q)>=0);tr.classList.toggle("hide",!ok);});}</script>'
|
|
64
|
-
|
|
65
|
-
def main():
|
|
66
|
-
ap = argparse.ArgumentParser()
|
|
67
|
-
ap.add_argument('--run-dir', default=None)
|
|
68
|
-
ap.add_argument('--out', default='test-report.html')
|
|
69
|
-
ap.add_argument('--embed', action='store_true', help='关键帧缩略图 base64 内嵌(单文件可分发)')
|
|
70
|
-
args = ap.parse_args()
|
|
71
|
-
|
|
72
|
-
global EMBED
|
|
73
|
-
EMBED = args.embed
|
|
74
|
-
cases = {c['id']: c for c in load('cases.json', [])}
|
|
75
|
-
if not cases:
|
|
76
|
-
import re as _re
|
|
77
|
-
for pf in glob.glob(os.path.join('testcases', 'T[CP]*.py')):
|
|
78
|
-
try:
|
|
79
|
-
src = open(pf, encoding='utf-8', errors='replace').read()
|
|
80
|
-
except Exception:
|
|
81
|
-
continue
|
|
82
|
-
mid = os.path.splitext(os.path.basename(pf))[0]
|
|
83
|
-
base = mid.split('_')[0]
|
|
84
|
-
m = _re.search(r'\"\"\"(.+?)\"\"\"', src, _re.S)
|
|
85
|
-
title = mid
|
|
86
|
-
if m:
|
|
87
|
-
title = m.group(1).strip().splitlines()[0][:50]
|
|
88
|
-
cases[base] = {'id': base, 'title': title, 'priority': '未分级',
|
|
89
|
-
'detail': (m.group(1).strip()[:600] if m else '')}
|
|
90
|
-
results = load('results.json', [])
|
|
91
|
-
scenarios = {s['id']: s for s in load('scenarios.json', [])}
|
|
92
|
-
run_dir = args.run_dir or find_latest_run()
|
|
93
|
-
|
|
94
|
-
# 结果 → 用例 id 归一(类名带 slug 后缀)
|
|
95
|
-
norm = []
|
|
96
|
-
for r in results:
|
|
97
|
-
rc = r.get('case', '')
|
|
98
|
-
cid = max((c for c in cases if rc == c or rc.startswith(c + '_')), key=len, default=rc)
|
|
99
|
-
norm.append((cid, r))
|
|
100
|
-
|
|
101
|
-
n = len(norm)
|
|
102
|
-
p = sum(1 for _, r in norm if r.get('status') == 'PASS')
|
|
103
|
-
fails = sum(1 for _, r in norm if r.get('status') == 'FAIL')
|
|
104
|
-
durs = [r.get('dur', 0) for _, r in norm]
|
|
105
|
-
total_dur = sum(durs)
|
|
106
|
-
|
|
107
|
-
pr_stat = {}
|
|
108
|
-
for cid, r in norm:
|
|
109
|
-
pr = cases.get(cid, {}).get('priority', '未分级')
|
|
110
|
-
pr_stat.setdefault(pr, [0, 0])
|
|
111
|
-
pr_stat[pr][0] += 1
|
|
112
|
-
pr_stat[pr][1] += 1 if r.get('status') == 'PASS' else 0
|
|
113
|
-
|
|
114
|
-
cov = {}
|
|
115
|
-
for cid, c in cases.items():
|
|
116
|
-
for fs in c.get('scenarios', []):
|
|
117
|
-
cov[fs] = cov.get(fs, 0) + 1
|
|
118
|
-
|
|
119
|
-
def shot(cid, rc='', suffix=''):
|
|
120
|
-
"""按 类名(rc) → 用例id 逐级匹配截图文件(类名带 slug 后缀,如 TC01_case/TC05_webview)"""
|
|
121
|
-
if not run_dir:
|
|
122
|
-
return None
|
|
123
|
-
for stem in (rc, cid):
|
|
124
|
-
for ext in ('.jpeg', '.jpg', '.png'):
|
|
125
|
-
f = os.path.join(run_dir, stem + suffix + ext)
|
|
126
|
-
if os.path.exists(f):
|
|
127
|
-
return os.path.relpath(f)
|
|
128
|
-
# 前缀兜底:TC05_webview 类名带 slug
|
|
129
|
-
pats = [os.path.join(run_dir, f'{cid}_*{suffix}.jpeg'), os.path.join(run_dir, f'{cid}_*{suffix}.jpg')]
|
|
130
|
-
for pat in pats:
|
|
131
|
-
g = sorted(glob.glob(pat))
|
|
132
|
-
if g:
|
|
133
|
-
return os.path.relpath(g[0])
|
|
134
|
-
return None
|
|
135
|
-
|
|
136
|
-
css = """
|
|
137
|
-
:root{--bg:#f4f6f9;--card:#fff;--ink:#20242c;--sub:#68707e;--line:#e5e8ee;
|
|
138
|
-
--ok:#0e9f6e;--bad:#e02424;--key:#3b6fd4}
|
|
139
|
-
*{box-sizing:border-box}
|
|
140
|
-
body{margin:0;font:14.5px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--ink)}
|
|
141
|
-
.wrap{max-width:1100px;margin:0 auto;padding:28px 20px 70px}
|
|
142
|
-
h1{font-size:23px;margin:.2em 0 .2em}
|
|
143
|
-
.meta{color:var(--sub);font-size:12.5px}
|
|
144
|
-
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin:18px 0}
|
|
145
|
-
.kpi{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:12px 14px}
|
|
146
|
-
.kpi b{display:block;font-size:22px}
|
|
147
|
-
.kpi span{font-size:12px;color:var(--sub)}
|
|
148
|
-
.pass b{color:var(--ok)}.fail b{color:var(--bad)}
|
|
149
|
-
.bar{display:flex;height:12px;border-radius:6px;overflow:hidden;margin:6px 0 2px}
|
|
150
|
-
.bar i{display:block;height:100%}
|
|
151
|
-
.lg{font-size:11.5px;color:var(--sub)}
|
|
152
|
-
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);
|
|
153
|
-
border-radius:9px;overflow:hidden;font-size:13px;margin:10px 0 4px}
|
|
154
|
-
th,td{padding:7px 10px;border-bottom:1px solid var(--line);text-align:left}
|
|
155
|
-
th{background:#eef2f8;font-size:12px}
|
|
156
|
-
h2{font-size:17px;border-left:4px solid var(--key);padding-left:9px;margin:30px 0 10px}
|
|
157
|
-
.case{background:var(--card);border:1px solid var(--line);border-left-width:4px;border-radius:9px;
|
|
158
|
-
padding:13px 16px;margin-bottom:12px}
|
|
159
|
-
.case.pass{border-left-color:var(--ok)}.case.fail{border-left-color:var(--bad)}
|
|
160
|
-
.hd{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline}
|
|
161
|
-
.hd b{font-size:15px}
|
|
162
|
-
.badge{font-size:11px;border-radius:4px;padding:1px 7px;color:#fff}
|
|
163
|
-
.b-pass{background:var(--ok)}.b-fail{background:var(--bad)}.b-l0{background:#b42318}
|
|
164
|
-
.b-l1{background:#c27803}.b-l2{background:#68707e}.b-dur{background:#3b6fd4}
|
|
165
|
-
.detail{background:#f8fafc;border-left:3px solid #cbd5e1;padding:8px 10px;font-size:12px;white-space:pre-wrap;color:#475569;margin:6px 0;max-height:220px;overflow:auto}
|
|
166
|
-
.steps{margin:8px 0 0;padding-left:0;list-style:none;font-size:13px}
|
|
167
|
-
.steps li{padding:2px 0 2px 18px;position:relative;color:#333}
|
|
168
|
-
.steps li:before{content:"▸";position:absolute;left:2px;color:#9aa3b2}
|
|
169
|
-
.steps code{background:#eef1f6;border-radius:3px;padding:0 5px;font-size:12px}
|
|
170
|
-
.reason{margin-top:8px;background:#fdf3f3;border:1px solid #f3c9c9;border-radius:7px;
|
|
171
|
-
padding:8px 11px;font:12px/1.6 ui-monospace,Menlo,monospace;color:#a31111;
|
|
172
|
-
white-space:pre-wrap;word-break:break-all}
|
|
173
|
-
.shots{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-top:10px}
|
|
174
|
-
.shots figure{margin:0;border:1px solid var(--line);border-radius:8px;padding:7px;text-align:center;background:#fafbfd}
|
|
175
|
-
.shots img{max-width:100%;max-height:330px;border-radius:5px;border:1px solid var(--line)}
|
|
176
|
-
.shots figcaption{font-size:11.5px;color:var(--sub);margin-top:4px}
|
|
177
|
-
.fs{font-size:12px;color:var(--sub);margin-top:4px}
|
|
178
|
-
.filters{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}
|
|
179
|
-
.filters button{border:1px solid var(--line);background:#fff;border-radius:14px;
|
|
180
|
-
padding:3px 13px;font-size:12.5px;cursor:pointer}
|
|
181
|
-
.filters button.on{background:var(--key);color:#fff;border-color:var(--key)}
|
|
182
|
-
.filters input{border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:12.5px;width:170px}
|
|
183
|
-
tr.hide{display:none}
|
|
184
|
-
"""
|
|
185
|
-
|
|
186
|
-
H = [f'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">',
|
|
187
|
-
'<meta name="viewport" content="width=device-width, initial-scale=1.0">',
|
|
188
|
-
'<title>测试执行报告</title>', f'<style>{css}</style></head><body><div class="wrap">',
|
|
189
|
-
f'<h1>测试执行报告 — {os.path.basename(os.getcwd())}</h1>',
|
|
190
|
-
f'<div class="meta">工程用例目录:{os.getcwd()} | 执行时间:{datetime.now():%Y-%m-%d %H:%M} | '
|
|
191
|
-
f'截图轮次:{os.path.relpath(run_dir) if run_dir else "无"}</div>']
|
|
192
|
-
|
|
193
|
-
# 概况
|
|
194
|
-
rate = f'{p}/{n}({p*100//n if n else 0}%)'
|
|
195
|
-
ok_pct = int(p * 100 / n) if n else 0
|
|
196
|
-
H += ['<div class="kpis">',
|
|
197
|
-
f'<div class="kpi pass"><b>{p}/{n}</b><span>通过</span></div>',
|
|
198
|
-
f'<div class="kpi fail"><b>{fails}</b><span>失败</span></div>',
|
|
199
|
-
f'<div class="kpi"><b>{rate}</b><span>通过率</span></div>',
|
|
200
|
-
f'<div class="kpi"><b>{total_dur:.0f}s</b><span>总耗时(单例均值 {total_dur/n if n else 0:.1f}s / 最长 {max(durs) if durs else 0:.0f}s)</span></div>',
|
|
201
|
-
f'<div class="kpi"><b>{len(cov)}/{len(scenarios) or "—"}</b><span>场景覆盖(引用数)</span></div>',
|
|
202
|
-
'</div>',
|
|
203
|
-
'<div class="bar">',
|
|
204
|
-
f'<i style="width:{ok_pct}%;background:var(--ok)"></i><i style="width:{100-ok_pct}%;background:var(--bad)"></i></div>',
|
|
205
|
-
f'<div class="lg">通过 {p} | 失败 {fails} | 优先级:'
|
|
206
|
-
+ ' / '.join(f'{k} {v[1]}/{v[0]}' for k, v in sorted(pr_stat.items()))
|
|
207
|
-
+ '</div>']
|
|
208
|
-
|
|
209
|
-
# 结果总览:可筛选表格
|
|
210
|
-
H += ['<h2>结果总览</h2>',
|
|
211
|
-
'<div class="filters">',
|
|
212
|
-
'<span style="font-size:12.5px;color:var(--sub)">状态:</span>',
|
|
213
|
-
'<button class="on" data-f="st" data-v="">全部</button>',
|
|
214
|
-
'<button data-f="st" data-v="PASS">PASS</button>',
|
|
215
|
-
'<button data-f="st" data-v="FAIL">FAIL</button>',
|
|
216
|
-
'<span style="font-size:12.5px;color:var(--sub)">优先级:</span>',
|
|
217
|
-
'<button class="on" data-f="pr" data-v="">全部</button>']
|
|
218
|
-
for pr in sorted(pr_stat):
|
|
219
|
-
H.append(f'<button data-f="pr" data-v="{pr}">{pr}</button>')
|
|
220
|
-
H += ['<input id="q" placeholder="搜索用例号/标题/失败原因…">',
|
|
221
|
-
'</div>',
|
|
222
|
-
'<table id="rt"><tr><th>用例</th><th>标题</th><th>结果</th><th>优先级</th>'
|
|
223
|
-
'<th>耗时</th><th>失败原因</th></tr>']
|
|
224
|
-
for cid, r in norm:
|
|
225
|
-
c = cases.get(cid, {})
|
|
226
|
-
st = r.get('status', '?')
|
|
227
|
-
stc = 'var(--ok)' if st == 'PASS' else 'var(--bad)'
|
|
228
|
-
pr = c.get('priority', '未分级')
|
|
229
|
-
msg = html.escape(str(r.get('msg', ''))[:120])
|
|
230
|
-
title = html.escape(str(c.get('title', '')))
|
|
231
|
-
H.append(f'<tr class="row" data-st="{st}" data-pr="{pr}" '
|
|
232
|
-
f'data-kw="{html.escape((cid + " " + c.get("title","") + " " + str(r.get("msg",""))).lower()[:200])}">'
|
|
233
|
-
f'<td><b>{cid}</b></td><td>{title}</td>'
|
|
234
|
-
f'<td style="color:{stc};font-weight:600">{st}</td><td>{pr}</td>'
|
|
235
|
-
f'<td>{r.get("dur","?")}s</td><td style="font-size:12px;max-width:340px">'
|
|
236
|
-
f'<span style="word-break:break-all">{msg or "—"}</span></td></tr>')
|
|
237
|
-
H += ['</table>', __JS__]
|
|
238
|
-
|
|
239
|
-
# 优先级表
|
|
240
|
-
H += ['<h2>优先级维度</h2>', '<table><tr><th>优先级</th><th>通过/总数</th><th>说明</th></tr>']
|
|
241
|
-
desc = {'L0': '冒烟/阻断——失败即阻断发版', 'L1': '重要功能——失败当版修复', 'L2': '次要/边界——择期处理', '未分级': '未标注优先级'}
|
|
242
|
-
for k, v in sorted(pr_stat.items()):
|
|
243
|
-
color = 'color:var(--ok)' if v[1] == v[0] else 'color:var(--bad)'
|
|
244
|
-
H.append(f'<tr><td><b>{k}</b></td><td style="{color}">{v[1]}/{v[0]}</td><td>{desc.get(k,"")}</td></tr>')
|
|
245
|
-
H += ['</table>']
|
|
246
|
-
|
|
247
|
-
# 用例明细
|
|
248
|
-
H += ['<h2>用例明细</h2>']
|
|
249
|
-
for cid, r in norm:
|
|
250
|
-
c = cases.get(cid, {})
|
|
251
|
-
st = r.get('status', '?')
|
|
252
|
-
cls = 'pass' if st == 'PASS' else 'fail'
|
|
253
|
-
pr = c.get('priority', '')
|
|
254
|
-
prb = f'<span class="badge b-{pr.lower() if pr else "l2"}">{pr}</span>' if pr else ''
|
|
255
|
-
fs_ids = c.get('scenarios', [])
|
|
256
|
-
fs_txt = '、'.join(f'{i}({scenarios[i]["name"]})' for i in fs_ids if i in scenarios) or '—'
|
|
257
|
-
H += [f'<div class="case {cls}">',
|
|
258
|
-
'<div class="hd">',
|
|
259
|
-
f'<span class="badge b-{"pass" if st=="PASS" else "fail"}">{st}</span>',
|
|
260
|
-
f'<b>{cid}</b>{prb}',
|
|
261
|
-
f'<span>{html.escape(str(c.get("title", "")))}</span>',
|
|
262
|
-
f'<span class="badge b-dur">{r.get("dur", "?")}s</span>',
|
|
263
|
-
'</div>',
|
|
264
|
-
f'<div class="fs">场景:{html.escape(fs_txt)}</div>']
|
|
265
|
-
steps = c.get('steps', [])
|
|
266
|
-
if steps:
|
|
267
|
-
H.append('<ul class="steps">')
|
|
268
|
-
for s_ in steps:
|
|
269
|
-
exp = {'exist': ' → 断言存在', 'absent': ' → 断言不存在'}.get(s_.get('expect'), '')
|
|
270
|
-
H.append(f'<li>{html.escape(str(s_.get("action","")))} '
|
|
271
|
-
f'<code>{html.escape(str(s_.get("target","")))}</code>{exp}</li>')
|
|
272
|
-
H.append('</ul>')
|
|
273
|
-
elif c.get('detail'): # 无设计稿(直写 py):docstring 行为链作为用例详情
|
|
274
|
-
H.append('<pre class="detail">')
|
|
275
|
-
H.append(html.escape(str(c['detail'])))
|
|
276
|
-
H.append('</pre>')
|
|
277
|
-
if r.get('msg'):
|
|
278
|
-
H.append(f'<div class="reason">失败原因:{html.escape(str(r["msg"])[:600])}</div>')
|
|
279
|
-
# 分步画廊:该用例全部步骤图(<case>_NN_label.jpeg)+ 结束/失败图
|
|
280
|
-
imgs = []
|
|
281
|
-
if run_dir:
|
|
282
|
-
pats = [os.path.join(run_dir, r.get('case', cid) + '_*.jpeg'),
|
|
283
|
-
os.path.join(run_dir, cid + '_*.jpeg'),
|
|
284
|
-
os.path.join(run_dir, cid + '_p*.jpeg')]
|
|
285
|
-
seen = set()
|
|
286
|
-
for pat in pats:
|
|
287
|
-
for g in sorted(glob.glob(pat)):
|
|
288
|
-
base = os.path.basename(g)
|
|
289
|
-
if base.endswith('_fail.jpeg') or base.endswith('_end.jpeg'):
|
|
290
|
-
continue
|
|
291
|
-
if base not in seen:
|
|
292
|
-
seen.add(base)
|
|
293
|
-
lbl = base.split('_', 2)[-1].rsplit('.', 1)[0]
|
|
294
|
-
imgs.append((os.path.relpath(g), '步骤:' + lbl))
|
|
295
|
-
s_fail = shot(cid, r.get('case', ''), '_fail')
|
|
296
|
-
if s_fail:
|
|
297
|
-
imgs.append((s_fail, '失败现场'))
|
|
298
|
-
s_end = shot(cid, r.get('case', ''), '_end')
|
|
299
|
-
if st == 'PASS' and s_end:
|
|
300
|
-
imgs.append((s_end, '用例结束页'))
|
|
301
|
-
if imgs:
|
|
302
|
-
H.append('<div class="shots">')
|
|
303
|
-
for i, (path, cap) in enumerate(imgs):
|
|
304
|
-
src = path
|
|
305
|
-
if EMBED: # 关键帧内嵌:fail/end 必嵌 + 过程帧首末张;其余保留相对路径(同目录可看全图)
|
|
306
|
-
key = ('失败现场' in cap) or ('结束页' in cap) or i in (0, len(imgs) - 2)
|
|
307
|
-
if key:
|
|
308
|
-
b64 = thumb_b64(path)
|
|
309
|
-
if b64:
|
|
310
|
-
src = b64
|
|
311
|
-
H.append(f'<figure><img src="{src}" loading="lazy"><figcaption>{cap}</figcaption></figure>')
|
|
312
|
-
H.append('</div>')
|
|
313
|
-
H.append('</div>')
|
|
314
|
-
|
|
315
|
-
H += ['<div class="meta" style="margin-top:30px">由 forge_report.py 生成 | '
|
|
316
|
-
'数据:results.json + cases.json + scenarios.json | 截图:run_direct 执行时自动采集</div>',
|
|
317
|
-
'</div></body></html>']
|
|
318
|
-
|
|
319
|
-
with open(args.out, 'w', encoding='utf-8') as f:
|
|
320
|
-
f.write('\n'.join(H))
|
|
321
|
-
print(f'[written] {args.out}({n} 用例 / PASS {p} / 截图目录 {os.path.relpath(run_dir) if run_dir else "无"})')
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if __name__ == '__main__':
|
|
325
|
-
main()
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""forge_report.py — 每工程测试执行报告(HTML,参考 Allure/extent 信息架构,零依赖)。
|
|
4
|
+
|
|
5
|
+
数据源(.hypium_forge/ 下):
|
|
6
|
+
results.json 最新一轮执行结果(case/status/msg/dur)
|
|
7
|
+
cases.json 用例(含 priority/scenarios/steps/title)
|
|
8
|
+
scenarios.json 场景注册表(可选,有则出场景维度)
|
|
9
|
+
reports/<d>/<ts>/ 截图:<case>.jpeg(成功)/ <case>_fail.jpeg(失败)
|
|
10
|
+
|
|
11
|
+
产出:test-report.html
|
|
12
|
+
① 执行概况:总数/通过率/优先级分布/耗时统计/场景覆盖
|
|
13
|
+
② 用例明细卡片:状态徽标、优先级、场景、步骤、失败原因、耗时、成功&失败截图
|
|
14
|
+
③ 结果条形总览(纯 CSS,按用例序排列)
|
|
15
|
+
|
|
16
|
+
用法(在 .hypium_forge 目录下):
|
|
17
|
+
python3 forge_report.py [--run-dir reports/2026-09-27/10-04-05] # 不指定则取最新
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import glob
|
|
21
|
+
import html
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
EMBED = False # --embed:关键帧 base64 内嵌开关
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def thumb_b64(path, max_w=480):
|
|
31
|
+
"""sips 缩略 → base64(macOS 零依赖;失败返回 None 走原相对路径)。"""
|
|
32
|
+
import base64 as _b64
|
|
33
|
+
import subprocess as _sp
|
|
34
|
+
import tempfile as _tf
|
|
35
|
+
try:
|
|
36
|
+
with _tf.NamedTemporaryFile(suffix='.jpeg', delete=False) as tf:
|
|
37
|
+
tmp = tf.name
|
|
38
|
+
_sp.run(['sips', '-Z', str(max_w), '-s', 'formatOptions', '55', path, '--out', tmp],
|
|
39
|
+
capture_output=True, timeout=15)
|
|
40
|
+
data = open(tmp, 'rb').read()
|
|
41
|
+
import os as _os
|
|
42
|
+
_os.unlink(tmp)
|
|
43
|
+
return 'data:image/jpeg;base64,' + _b64.b64encode(data).decode()
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load(path, default=None):
|
|
49
|
+
if os.path.exists(path):
|
|
50
|
+
with open(path, encoding='utf-8') as f:
|
|
51
|
+
return json.load(f)
|
|
52
|
+
return default
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_latest_run():
|
|
56
|
+
runs = sorted(glob.glob(os.path.join('reports', '*', '*')))
|
|
57
|
+
dirs = [r for r in runs if os.path.isdir(r) and
|
|
58
|
+
(glob.glob(os.path.join(r, '*.jpeg')) or glob.glob(os.path.join(r, '*.jpg')) or
|
|
59
|
+
glob.glob(os.path.join(r, '*.png')))]
|
|
60
|
+
return dirs[-1] if dirs else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__JS__ = '<script>var btns=document.querySelectorAll(".filters button");var f={st:"",pr:""};btns.forEach(function(b){b.onclick=function(){document.querySelectorAll(\'[data-f="\'+b.dataset.f+\'"]\').forEach(function(x){x.classList.remove("on")});b.classList.add("on");f[b.dataset.f]=b.dataset.v;apply();};});document.getElementById("q").oninput=apply;function apply(){var q=document.getElementById("q").value.toLowerCase();document.querySelectorAll("tr.row").forEach(function(tr){var ok=(!f.st||tr.dataset.st===f.st)&&(!f.pr||tr.dataset.pr===f.pr)&&(!q||tr.dataset.kw.indexOf(q)>=0);tr.classList.toggle("hide",!ok);});}</script>'
|
|
64
|
+
|
|
65
|
+
def main():
|
|
66
|
+
ap = argparse.ArgumentParser()
|
|
67
|
+
ap.add_argument('--run-dir', default=None)
|
|
68
|
+
ap.add_argument('--out', default='test-report.html')
|
|
69
|
+
ap.add_argument('--embed', action='store_true', help='关键帧缩略图 base64 内嵌(单文件可分发)')
|
|
70
|
+
args = ap.parse_args()
|
|
71
|
+
|
|
72
|
+
global EMBED
|
|
73
|
+
EMBED = args.embed
|
|
74
|
+
cases = {c['id']: c for c in load('cases.json', [])}
|
|
75
|
+
if not cases:
|
|
76
|
+
import re as _re
|
|
77
|
+
for pf in glob.glob(os.path.join('testcases', 'T[CP]*.py')):
|
|
78
|
+
try:
|
|
79
|
+
src = open(pf, encoding='utf-8', errors='replace').read()
|
|
80
|
+
except Exception:
|
|
81
|
+
continue
|
|
82
|
+
mid = os.path.splitext(os.path.basename(pf))[0]
|
|
83
|
+
base = mid.split('_')[0]
|
|
84
|
+
m = _re.search(r'\"\"\"(.+?)\"\"\"', src, _re.S)
|
|
85
|
+
title = mid
|
|
86
|
+
if m:
|
|
87
|
+
title = m.group(1).strip().splitlines()[0][:50]
|
|
88
|
+
cases[base] = {'id': base, 'title': title, 'priority': '未分级',
|
|
89
|
+
'detail': (m.group(1).strip()[:600] if m else '')}
|
|
90
|
+
results = load('results.json', [])
|
|
91
|
+
scenarios = {s['id']: s for s in load('scenarios.json', [])}
|
|
92
|
+
run_dir = args.run_dir or find_latest_run()
|
|
93
|
+
|
|
94
|
+
# 结果 → 用例 id 归一(类名带 slug 后缀)
|
|
95
|
+
norm = []
|
|
96
|
+
for r in results:
|
|
97
|
+
rc = r.get('case', '')
|
|
98
|
+
cid = max((c for c in cases if rc == c or rc.startswith(c + '_')), key=len, default=rc)
|
|
99
|
+
norm.append((cid, r))
|
|
100
|
+
|
|
101
|
+
n = len(norm)
|
|
102
|
+
p = sum(1 for _, r in norm if r.get('status') == 'PASS')
|
|
103
|
+
fails = sum(1 for _, r in norm if r.get('status') == 'FAIL')
|
|
104
|
+
durs = [r.get('dur', 0) for _, r in norm]
|
|
105
|
+
total_dur = sum(durs)
|
|
106
|
+
|
|
107
|
+
pr_stat = {}
|
|
108
|
+
for cid, r in norm:
|
|
109
|
+
pr = cases.get(cid, {}).get('priority', '未分级')
|
|
110
|
+
pr_stat.setdefault(pr, [0, 0])
|
|
111
|
+
pr_stat[pr][0] += 1
|
|
112
|
+
pr_stat[pr][1] += 1 if r.get('status') == 'PASS' else 0
|
|
113
|
+
|
|
114
|
+
cov = {}
|
|
115
|
+
for cid, c in cases.items():
|
|
116
|
+
for fs in c.get('scenarios', []):
|
|
117
|
+
cov[fs] = cov.get(fs, 0) + 1
|
|
118
|
+
|
|
119
|
+
def shot(cid, rc='', suffix=''):
|
|
120
|
+
"""按 类名(rc) → 用例id 逐级匹配截图文件(类名带 slug 后缀,如 TC01_case/TC05_webview)"""
|
|
121
|
+
if not run_dir:
|
|
122
|
+
return None
|
|
123
|
+
for stem in (rc, cid):
|
|
124
|
+
for ext in ('.jpeg', '.jpg', '.png'):
|
|
125
|
+
f = os.path.join(run_dir, stem + suffix + ext)
|
|
126
|
+
if os.path.exists(f):
|
|
127
|
+
return os.path.relpath(f)
|
|
128
|
+
# 前缀兜底:TC05_webview 类名带 slug
|
|
129
|
+
pats = [os.path.join(run_dir, f'{cid}_*{suffix}.jpeg'), os.path.join(run_dir, f'{cid}_*{suffix}.jpg')]
|
|
130
|
+
for pat in pats:
|
|
131
|
+
g = sorted(glob.glob(pat))
|
|
132
|
+
if g:
|
|
133
|
+
return os.path.relpath(g[0])
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
css = """
|
|
137
|
+
:root{--bg:#f4f6f9;--card:#fff;--ink:#20242c;--sub:#68707e;--line:#e5e8ee;
|
|
138
|
+
--ok:#0e9f6e;--bad:#e02424;--key:#3b6fd4}
|
|
139
|
+
*{box-sizing:border-box}
|
|
140
|
+
body{margin:0;font:14.5px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--ink)}
|
|
141
|
+
.wrap{max-width:1100px;margin:0 auto;padding:28px 20px 70px}
|
|
142
|
+
h1{font-size:23px;margin:.2em 0 .2em}
|
|
143
|
+
.meta{color:var(--sub);font-size:12.5px}
|
|
144
|
+
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin:18px 0}
|
|
145
|
+
.kpi{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:12px 14px}
|
|
146
|
+
.kpi b{display:block;font-size:22px}
|
|
147
|
+
.kpi span{font-size:12px;color:var(--sub)}
|
|
148
|
+
.pass b{color:var(--ok)}.fail b{color:var(--bad)}
|
|
149
|
+
.bar{display:flex;height:12px;border-radius:6px;overflow:hidden;margin:6px 0 2px}
|
|
150
|
+
.bar i{display:block;height:100%}
|
|
151
|
+
.lg{font-size:11.5px;color:var(--sub)}
|
|
152
|
+
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);
|
|
153
|
+
border-radius:9px;overflow:hidden;font-size:13px;margin:10px 0 4px}
|
|
154
|
+
th,td{padding:7px 10px;border-bottom:1px solid var(--line);text-align:left}
|
|
155
|
+
th{background:#eef2f8;font-size:12px}
|
|
156
|
+
h2{font-size:17px;border-left:4px solid var(--key);padding-left:9px;margin:30px 0 10px}
|
|
157
|
+
.case{background:var(--card);border:1px solid var(--line);border-left-width:4px;border-radius:9px;
|
|
158
|
+
padding:13px 16px;margin-bottom:12px}
|
|
159
|
+
.case.pass{border-left-color:var(--ok)}.case.fail{border-left-color:var(--bad)}
|
|
160
|
+
.hd{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline}
|
|
161
|
+
.hd b{font-size:15px}
|
|
162
|
+
.badge{font-size:11px;border-radius:4px;padding:1px 7px;color:#fff}
|
|
163
|
+
.b-pass{background:var(--ok)}.b-fail{background:var(--bad)}.b-l0{background:#b42318}
|
|
164
|
+
.b-l1{background:#c27803}.b-l2{background:#68707e}.b-dur{background:#3b6fd4}
|
|
165
|
+
.detail{background:#f8fafc;border-left:3px solid #cbd5e1;padding:8px 10px;font-size:12px;white-space:pre-wrap;color:#475569;margin:6px 0;max-height:220px;overflow:auto}
|
|
166
|
+
.steps{margin:8px 0 0;padding-left:0;list-style:none;font-size:13px}
|
|
167
|
+
.steps li{padding:2px 0 2px 18px;position:relative;color:#333}
|
|
168
|
+
.steps li:before{content:"▸";position:absolute;left:2px;color:#9aa3b2}
|
|
169
|
+
.steps code{background:#eef1f6;border-radius:3px;padding:0 5px;font-size:12px}
|
|
170
|
+
.reason{margin-top:8px;background:#fdf3f3;border:1px solid #f3c9c9;border-radius:7px;
|
|
171
|
+
padding:8px 11px;font:12px/1.6 ui-monospace,Menlo,monospace;color:#a31111;
|
|
172
|
+
white-space:pre-wrap;word-break:break-all}
|
|
173
|
+
.shots{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-top:10px}
|
|
174
|
+
.shots figure{margin:0;border:1px solid var(--line);border-radius:8px;padding:7px;text-align:center;background:#fafbfd}
|
|
175
|
+
.shots img{max-width:100%;max-height:330px;border-radius:5px;border:1px solid var(--line)}
|
|
176
|
+
.shots figcaption{font-size:11.5px;color:var(--sub);margin-top:4px}
|
|
177
|
+
.fs{font-size:12px;color:var(--sub);margin-top:4px}
|
|
178
|
+
.filters{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}
|
|
179
|
+
.filters button{border:1px solid var(--line);background:#fff;border-radius:14px;
|
|
180
|
+
padding:3px 13px;font-size:12.5px;cursor:pointer}
|
|
181
|
+
.filters button.on{background:var(--key);color:#fff;border-color:var(--key)}
|
|
182
|
+
.filters input{border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:12.5px;width:170px}
|
|
183
|
+
tr.hide{display:none}
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
H = [f'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">',
|
|
187
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1.0">',
|
|
188
|
+
'<title>测试执行报告</title>', f'<style>{css}</style></head><body><div class="wrap">',
|
|
189
|
+
f'<h1>测试执行报告 — {os.path.basename(os.getcwd())}</h1>',
|
|
190
|
+
f'<div class="meta">工程用例目录:{os.getcwd()} | 执行时间:{datetime.now():%Y-%m-%d %H:%M} | '
|
|
191
|
+
f'截图轮次:{os.path.relpath(run_dir) if run_dir else "无"}</div>']
|
|
192
|
+
|
|
193
|
+
# 概况
|
|
194
|
+
rate = f'{p}/{n}({p*100//n if n else 0}%)'
|
|
195
|
+
ok_pct = int(p * 100 / n) if n else 0
|
|
196
|
+
H += ['<div class="kpis">',
|
|
197
|
+
f'<div class="kpi pass"><b>{p}/{n}</b><span>通过</span></div>',
|
|
198
|
+
f'<div class="kpi fail"><b>{fails}</b><span>失败</span></div>',
|
|
199
|
+
f'<div class="kpi"><b>{rate}</b><span>通过率</span></div>',
|
|
200
|
+
f'<div class="kpi"><b>{total_dur:.0f}s</b><span>总耗时(单例均值 {total_dur/n if n else 0:.1f}s / 最长 {max(durs) if durs else 0:.0f}s)</span></div>',
|
|
201
|
+
f'<div class="kpi"><b>{len(cov)}/{len(scenarios) or "—"}</b><span>场景覆盖(引用数)</span></div>',
|
|
202
|
+
'</div>',
|
|
203
|
+
'<div class="bar">',
|
|
204
|
+
f'<i style="width:{ok_pct}%;background:var(--ok)"></i><i style="width:{100-ok_pct}%;background:var(--bad)"></i></div>',
|
|
205
|
+
f'<div class="lg">通过 {p} | 失败 {fails} | 优先级:'
|
|
206
|
+
+ ' / '.join(f'{k} {v[1]}/{v[0]}' for k, v in sorted(pr_stat.items()))
|
|
207
|
+
+ '</div>']
|
|
208
|
+
|
|
209
|
+
# 结果总览:可筛选表格
|
|
210
|
+
H += ['<h2>结果总览</h2>',
|
|
211
|
+
'<div class="filters">',
|
|
212
|
+
'<span style="font-size:12.5px;color:var(--sub)">状态:</span>',
|
|
213
|
+
'<button class="on" data-f="st" data-v="">全部</button>',
|
|
214
|
+
'<button data-f="st" data-v="PASS">PASS</button>',
|
|
215
|
+
'<button data-f="st" data-v="FAIL">FAIL</button>',
|
|
216
|
+
'<span style="font-size:12.5px;color:var(--sub)">优先级:</span>',
|
|
217
|
+
'<button class="on" data-f="pr" data-v="">全部</button>']
|
|
218
|
+
for pr in sorted(pr_stat):
|
|
219
|
+
H.append(f'<button data-f="pr" data-v="{pr}">{pr}</button>')
|
|
220
|
+
H += ['<input id="q" placeholder="搜索用例号/标题/失败原因…">',
|
|
221
|
+
'</div>',
|
|
222
|
+
'<table id="rt"><tr><th>用例</th><th>标题</th><th>结果</th><th>优先级</th>'
|
|
223
|
+
'<th>耗时</th><th>失败原因</th></tr>']
|
|
224
|
+
for cid, r in norm:
|
|
225
|
+
c = cases.get(cid, {})
|
|
226
|
+
st = r.get('status', '?')
|
|
227
|
+
stc = 'var(--ok)' if st == 'PASS' else 'var(--bad)'
|
|
228
|
+
pr = c.get('priority', '未分级')
|
|
229
|
+
msg = html.escape(str(r.get('msg', ''))[:120])
|
|
230
|
+
title = html.escape(str(c.get('title', '')))
|
|
231
|
+
H.append(f'<tr class="row" data-st="{st}" data-pr="{pr}" '
|
|
232
|
+
f'data-kw="{html.escape((cid + " " + c.get("title","") + " " + str(r.get("msg",""))).lower()[:200])}">'
|
|
233
|
+
f'<td><b>{cid}</b></td><td>{title}</td>'
|
|
234
|
+
f'<td style="color:{stc};font-weight:600">{st}</td><td>{pr}</td>'
|
|
235
|
+
f'<td>{r.get("dur","?")}s</td><td style="font-size:12px;max-width:340px">'
|
|
236
|
+
f'<span style="word-break:break-all">{msg or "—"}</span></td></tr>')
|
|
237
|
+
H += ['</table>', __JS__]
|
|
238
|
+
|
|
239
|
+
# 优先级表
|
|
240
|
+
H += ['<h2>优先级维度</h2>', '<table><tr><th>优先级</th><th>通过/总数</th><th>说明</th></tr>']
|
|
241
|
+
desc = {'L0': '冒烟/阻断——失败即阻断发版', 'L1': '重要功能——失败当版修复', 'L2': '次要/边界——择期处理', '未分级': '未标注优先级'}
|
|
242
|
+
for k, v in sorted(pr_stat.items()):
|
|
243
|
+
color = 'color:var(--ok)' if v[1] == v[0] else 'color:var(--bad)'
|
|
244
|
+
H.append(f'<tr><td><b>{k}</b></td><td style="{color}">{v[1]}/{v[0]}</td><td>{desc.get(k,"")}</td></tr>')
|
|
245
|
+
H += ['</table>']
|
|
246
|
+
|
|
247
|
+
# 用例明细
|
|
248
|
+
H += ['<h2>用例明细</h2>']
|
|
249
|
+
for cid, r in norm:
|
|
250
|
+
c = cases.get(cid, {})
|
|
251
|
+
st = r.get('status', '?')
|
|
252
|
+
cls = 'pass' if st == 'PASS' else 'fail'
|
|
253
|
+
pr = c.get('priority', '')
|
|
254
|
+
prb = f'<span class="badge b-{pr.lower() if pr else "l2"}">{pr}</span>' if pr else ''
|
|
255
|
+
fs_ids = c.get('scenarios', [])
|
|
256
|
+
fs_txt = '、'.join(f'{i}({scenarios[i]["name"]})' for i in fs_ids if i in scenarios) or '—'
|
|
257
|
+
H += [f'<div class="case {cls}">',
|
|
258
|
+
'<div class="hd">',
|
|
259
|
+
f'<span class="badge b-{"pass" if st=="PASS" else "fail"}">{st}</span>',
|
|
260
|
+
f'<b>{cid}</b>{prb}',
|
|
261
|
+
f'<span>{html.escape(str(c.get("title", "")))}</span>',
|
|
262
|
+
f'<span class="badge b-dur">{r.get("dur", "?")}s</span>',
|
|
263
|
+
'</div>',
|
|
264
|
+
f'<div class="fs">场景:{html.escape(fs_txt)}</div>']
|
|
265
|
+
steps = c.get('steps', [])
|
|
266
|
+
if steps:
|
|
267
|
+
H.append('<ul class="steps">')
|
|
268
|
+
for s_ in steps:
|
|
269
|
+
exp = {'exist': ' → 断言存在', 'absent': ' → 断言不存在'}.get(s_.get('expect'), '')
|
|
270
|
+
H.append(f'<li>{html.escape(str(s_.get("action","")))} '
|
|
271
|
+
f'<code>{html.escape(str(s_.get("target","")))}</code>{exp}</li>')
|
|
272
|
+
H.append('</ul>')
|
|
273
|
+
elif c.get('detail'): # 无设计稿(直写 py):docstring 行为链作为用例详情
|
|
274
|
+
H.append('<pre class="detail">')
|
|
275
|
+
H.append(html.escape(str(c['detail'])))
|
|
276
|
+
H.append('</pre>')
|
|
277
|
+
if r.get('msg'):
|
|
278
|
+
H.append(f'<div class="reason">失败原因:{html.escape(str(r["msg"])[:600])}</div>')
|
|
279
|
+
# 分步画廊:该用例全部步骤图(<case>_NN_label.jpeg)+ 结束/失败图
|
|
280
|
+
imgs = []
|
|
281
|
+
if run_dir:
|
|
282
|
+
pats = [os.path.join(run_dir, r.get('case', cid) + '_*.jpeg'),
|
|
283
|
+
os.path.join(run_dir, cid + '_*.jpeg'),
|
|
284
|
+
os.path.join(run_dir, cid + '_p*.jpeg')]
|
|
285
|
+
seen = set()
|
|
286
|
+
for pat in pats:
|
|
287
|
+
for g in sorted(glob.glob(pat)):
|
|
288
|
+
base = os.path.basename(g)
|
|
289
|
+
if base.endswith('_fail.jpeg') or base.endswith('_end.jpeg'):
|
|
290
|
+
continue
|
|
291
|
+
if base not in seen:
|
|
292
|
+
seen.add(base)
|
|
293
|
+
lbl = base.split('_', 2)[-1].rsplit('.', 1)[0]
|
|
294
|
+
imgs.append((os.path.relpath(g), '步骤:' + lbl))
|
|
295
|
+
s_fail = shot(cid, r.get('case', ''), '_fail')
|
|
296
|
+
if s_fail:
|
|
297
|
+
imgs.append((s_fail, '失败现场'))
|
|
298
|
+
s_end = shot(cid, r.get('case', ''), '_end')
|
|
299
|
+
if st == 'PASS' and s_end:
|
|
300
|
+
imgs.append((s_end, '用例结束页'))
|
|
301
|
+
if imgs:
|
|
302
|
+
H.append('<div class="shots">')
|
|
303
|
+
for i, (path, cap) in enumerate(imgs):
|
|
304
|
+
src = path
|
|
305
|
+
if EMBED: # 关键帧内嵌:fail/end 必嵌 + 过程帧首末张;其余保留相对路径(同目录可看全图)
|
|
306
|
+
key = ('失败现场' in cap) or ('结束页' in cap) or i in (0, len(imgs) - 2)
|
|
307
|
+
if key:
|
|
308
|
+
b64 = thumb_b64(path)
|
|
309
|
+
if b64:
|
|
310
|
+
src = b64
|
|
311
|
+
H.append(f'<figure><img src="{src}" loading="lazy"><figcaption>{cap}</figcaption></figure>')
|
|
312
|
+
H.append('</div>')
|
|
313
|
+
H.append('</div>')
|
|
314
|
+
|
|
315
|
+
H += ['<div class="meta" style="margin-top:30px">由 forge_report.py 生成 | '
|
|
316
|
+
'数据:results.json + cases.json + scenarios.json | 截图:run_direct 执行时自动采集</div>',
|
|
317
|
+
'</div></body></html>']
|
|
318
|
+
|
|
319
|
+
with open(args.out, 'w', encoding='utf-8') as f:
|
|
320
|
+
f.write('\n'.join(H))
|
|
321
|
+
print(f'[written] {args.out}({n} 用例 / PASS {p} / 截图目录 {os.path.relpath(run_dir) if run_dir else "无"})')
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == '__main__':
|
|
325
|
+
main()
|