@harmonyos-arkts/d2h 0.1.11 → 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.
Files changed (126) hide show
  1. package/.claude-plugin/marketplace.json +15 -13
  2. package/.claude-plugin/plugin.json +13 -7
  3. package/AGENTS.md +5 -5
  4. package/README.md +212 -212
  5. package/agents/android-to-hmos-00-orchestrator.md +318 -318
  6. package/agents/d2h.md +157 -157
  7. package/dist/bin/install-opencode.js +30 -30
  8. package/docs/environment-check.md +86 -86
  9. package/install-opencode.sh +160 -160
  10. package/opencode/agents.json +16 -16
  11. package/opencode/vscode-d2h-package.json +7 -7
  12. package/opencode/vscode-d2h-plugin.mjs +29 -29
  13. package/package.json +54 -54
  14. package/schemas/android-source-manifest.schema.json +25 -25
  15. package/schemas/checkpoint-provenance.schema.json +67 -67
  16. package/schemas/final-acceptance.schema.json +29 -29
  17. package/schemas/managed-evidence-index.schema.json +15 -15
  18. package/schemas/managed-evidence.schema.json +114 -114
  19. package/schemas/migration-config.schema.json +51 -51
  20. package/schemas/migration-report-index.schema.json +39 -39
  21. package/schemas/migration-report-item.schema.json +51 -51
  22. package/schemas/migration-report-summary.schema.json +25 -25
  23. package/schemas/migration-status.schema.json +202 -202
  24. package/schemas/preflight.schema.json +60 -60
  25. package/schemas/source-order-audit.schema.json +16 -16
  26. package/schemas/source-provenance-event.schema.json +19 -19
  27. package/schemas/spec-app-shard.schema.json +45 -45
  28. package/schemas/spec-fact-corrections-shard.schema.json +13 -13
  29. package/schemas/spec-features-shard.schema.json +43 -43
  30. package/schemas/spec-index.schema.json +26 -26
  31. package/schemas/spec-interactions-shard.schema.json +40 -40
  32. package/schemas/spec-page.schema.json +98 -98
  33. package/schemas/spec-pages-index.schema.json +1 -1
  34. package/schemas/spec-unresolved-shard.schema.json +1 -1
  35. package/schemas/task-envelope.schema.json +109 -109
  36. package/schemas/task-plan.schema.json +341 -341
  37. package/scripts/check-environment.mjs +320 -320
  38. package/skills/android2hmos_resources_convert/SKILL.md +180 -180
  39. package/skills/android2hmos_resources_convert/references/image-conversion-rules.md +230 -230
  40. package/skills/android2hmos_resources_convert/references/svg-fix-patterns.md +175 -175
  41. package/skills/android2hmos_resources_convert/references/xml-drawable-to-svg-rules.md +513 -513
  42. package/skills/arkts-app-identity/SKILL.md +238 -238
  43. package/skills/arkts-i18n/SKILL.md +496 -496
  44. package/skills/arkts-i18n/evals/evals.json +84 -84
  45. package/skills/arkts-i18n/references/code-examples.md +302 -302
  46. package/skills/arkts-i18n/references/common-pitfalls.md +391 -391
  47. package/skills/arkts-i18n/references/dynamic-language-switch.md +604 -604
  48. package/skills/arkts-i18n/references/hardcoded-string-scanner.md +348 -348
  49. package/skills/arkts-i18n/references/language-codes.md +104 -104
  50. package/skills/arkts-i18n/references/resource-file-structure.md +775 -775
  51. package/skills/arkts-i18n/references/static-vs-dynamic.md +242 -242
  52. package/skills/arkts-i18n/references/v1-compat.md +244 -244
  53. package/skills/arkts-i18n/scripts/audit_i18n_completeness.sh +174 -174
  54. package/skills/arkts-icon-sizing/SKILL.md +211 -211
  55. package/skills/arkts-icon-sizing/scripts/icon_audit.py +131 -131
  56. package/skills/arkts-icon-sizing/scripts/icon_autofix.py +88 -88
  57. package/skills/arkts-icon-sizing/scripts/icon_dims.py +179 -179
  58. package/skills/arkts-icon-sizing/scripts/icon_fix.py +119 -119
  59. package/skills/arkts-mvvm-architecture/SKILL.md +613 -613
  60. package/skills/d2h-hypium-forge/SKILL.md +309 -309
  61. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -140
  62. package/skills/d2h-hypium-forge/references/official-api.md +135 -135
  63. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -98
  64. package/skills/d2h-hypium-forge/references/testability-template.md +71 -71
  65. package/skills/d2h-hypium-forge/scripts/_common_template.py +659 -659
  66. package/skills/d2h-hypium-forge/scripts/analyze_results.py +165 -165
  67. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -107
  68. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -169
  69. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -325
  70. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -114
  71. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -80
  72. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -60
  73. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -249
  74. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -140
  75. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -232
  76. package/skills/d2h-hypium-forge/scripts/probe.py +197 -197
  77. package/skills/d2h-hypium-forge/scripts/run_direct.py +823 -823
  78. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -121
  79. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -255
  80. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -66
  81. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -84
  82. package/skills/d2h-page-develop/SKILL.md +112 -112
  83. package/skills/d2h-page-develop/agents/openai.yaml +7 -7
  84. package/skills/d2h-page-spec-generate/SKILL.md +206 -206
  85. package/skills/d2h-page-spec-generate/scripts/build_batch_report.py +227 -227
  86. package/skills/d2h-utg-ai-generate/SKILL.md +128 -128
  87. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -76
  88. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -34
  89. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -96
  90. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -61
  91. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -89
  92. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -314
  93. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -123
  94. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -280
  95. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -524
  96. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -489
  97. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -69
  98. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -13
  99. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -2
  100. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -369
  101. package/skills/d2h-utg-static-generate/SKILL.md +73 -73
  102. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -38
  103. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -499
  104. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -335
  105. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -60
  106. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -131
  107. package/skills/harmonyos-migration-playbook/SKILL.md +83 -83
  108. package/skills/harmonyos-migration-playbook/agents/openai.yaml +7 -7
  109. package/skills/harmonyos-migration-playbook/references/arkts-compile.md +24 -24
  110. package/skills/harmonyos-migration-playbook/references/harmony-runtime.md +53 -53
  111. package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +45 -45
  112. package/skills/harmonyos-migration-playbook/references/protocol-e2e.md +23 -23
  113. package/skills/harmonyos-migration-playbook/references/ui-automation.md +53 -53
  114. package/skills/harmonyos-migration-playbook/references/windows-environment.md +38 -38
  115. package/skills/maintaining-migration-report/SKILL.md +144 -144
  116. package/skills/maintaining-migration-report/references/final-acceptance.md +48 -48
  117. package/skills/maintaining-migration-report/references/source-provenance.md +66 -66
  118. package/skills/managing-d2h-toolchain/SKILL.md +24 -24
  119. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -16
  120. package/skills/managing-d2h-toolchain/scripts/manage-toolchain.mjs +98 -98
  121. package/skills/managing-d2h-toolchain/toolchain.json +16 -16
  122. package/skills/native-library-substitution/SKILL.md +384 -384
  123. package/skills/native-library-substitution/references/native-library-substitution.json +56905 -56905
  124. package/skills/native-library-substitution/references/native-library-substitution.md +163 -163
  125. package/skills/preparing-migration-workspace/SKILL.md +131 -131
  126. 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()