@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,823 +1,823 @@
1
- #!/usr/bin/env python3
2
- """
3
- run_direct.py — 裸 hypium 直跑器(无 devicetest/xdevice 依赖)+ 前置条件分组编排。
4
-
5
- 编排规则(TD-004 修正:共用前置条件,防交错破坏):
6
- 用例文件可声明模块级常量 PRECOND(缺省 'anonymous'):
7
- - 'anonymous' 无状态要求(软启动即可)
8
- - 'logged_in' 需登录态(组内软隔离保持 preferences,ensure_login 幂等复用)
9
- - 'first_launch' 需首启态(bm clean 破坏性最大,强制排尾,不再破坏后续)
10
- 执行顺序 = 组序(anonymous → logged_in → first_launch)→ 组内文件名序。
11
- 组切换打印分隔;results.json 每条附 group 字段(可分组统计)。
12
- 组首条置 os.environ['FORGE_GROUP_FIRST']='1'(用例可选做完整冷启验证),执行后清除。
13
-
14
- 用法:
15
- 单条: python3 run_direct.py --case F01_func_normal_001 [--dir testcases]
16
- 批量: python3 run_direct.py --all [--dir testcases]
17
- 子串: python3 run_direct.py --filter TC3 [--dir testcases] # 跑文件名含 TC3 的用例(保留分组序)
18
-
19
- 截图与留存(防 reports/ 无限膨胀——Grit 实证 15 轮全量 5266 张/666MB):
20
- --shots fail 默认。PASS 用例仅留 <case>_end.jpeg;过程帧(_pNN)/分步图 仅 FAIL 时保留;
21
- 连续相同帧自动去重(静态页面 8 帧 → 1 帧)
22
- --shots all 全程留存(需逐步回放页面时用)
23
- --shots off 完全不截图(纯冒烟)
24
- --acceptance 验收模式:强制全量 + 全程截图,目录写 .acceptance 标记独立保护(保留最近 2 次)
25
- --keep-reports N 中间(非验收)运行目录保留最近 N 份(默认 0=即用即弃;env FORGE_KEEP_REPORTS)
26
- env FORGE_SHOT_MODE 同 --shots(CLI 优先)
27
-
28
- 要求: 用例 .py 文件含 process_case(d) 顶层函数;或兼容 devicetest 风格(反射 process 方法)。
29
- 结果: 逐条打印 PASS/FAIL + 汇总,写 results.json
30
- """
31
- import argparse
32
- import hashlib
33
- import importlib.util
34
- import json
35
- import os
36
- import re
37
- import sys
38
- import time
39
- import traceback
40
-
41
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
42
-
43
- from hypium import UiDriver
44
-
45
- # 前置组执行序:破坏性递增(first_launch 会 bm clean,必须排尾)
46
- GROUP_ORDER = {'anonymous': 0, 'logged_in': 1, 'first_launch': 2}
47
-
48
-
49
- def load_case(path):
50
- spec = importlib.util.spec_from_file_location('case_mod', path)
51
- mod = importlib.util.module_from_spec(spec)
52
- spec.loader.exec_module(mod)
53
- return mod
54
-
55
-
56
- def case_group(mod):
57
- g = str(getattr(mod, 'PRECOND', 'anonymous') or 'anonymous')
58
- return g if g in GROUP_ORDER else 'anonymous'
59
-
60
-
61
- def order_files(dir_):
62
- """返回 [(filename, group)],按 组序→文件名 排序;只 import 一次读 PRECOND。"""
63
- files = sorted(f for f in os.listdir(dir_) if f.endswith('.py') and not f.startswith('_'))
64
- tagged = []
65
- for f in files:
66
- try:
67
- mod = load_case(os.path.join(dir_, f))
68
- tagged.append((f, case_group(mod)))
69
- except Exception:
70
- tagged.append((f, 'anonymous')) # import 失败留给 run_one 报 FAIL,不在此拦截
71
- tagged.sort(key=lambda x: (GROUP_ORDER[x[1]], x[0]))
72
- return tagged
73
-
74
-
75
- def _scene_evidence(d, n=8):
76
- # FAIL 现场证据: 当前页面 Text 摘要(进 results.msg, 供下轮 LLM 直读省重取证)
77
- try:
78
- ts = [(c.getText() or '')[:12] for c in (d.find_all_components(BY.type('Text')) or [])]
79
- ts = [t for t in ts if t][:n]
80
- return ' | 现场Text: ' + '/'.join(ts) if ts else ' | 现场Text: (空)'
81
- except Exception:
82
- return ''
83
-
84
-
85
- def _file_digest(path):
86
- try:
87
- return hashlib.sha1(open(path, 'rb').read()).hexdigest()
88
- except Exception:
89
- return None
90
-
91
-
92
- def _clean_pass_shots(name):
93
- """shots=fail 模式:PASS 用例只留 <case>_end.jpeg,删过程帧(_pNN)与分步图(_NN_label)。"""
94
- if not SHOT_DIR:
95
- return
96
- import glob as _g
97
- freed = 0
98
- for pat in (os.path.join(SHOT_DIR, name + '_p*.jpeg'),
99
- os.path.join(SHOT_DIR, name + '_fail.jpeg')):
100
- for p in _g.glob(pat):
101
- try:
102
- freed += os.path.getsize(p)
103
- os.remove(p)
104
- except OSError:
105
- pass
106
- for p in _g.glob(os.path.join(SHOT_DIR, name + '_*.jpeg')):
107
- base = os.path.basename(p)
108
- if re.match(r'^%s_\d{2}_.*\.jpeg$' % re.escape(name), base): # step_shot 分步图
109
- try:
110
- freed += os.path.getsize(p)
111
- os.remove(p)
112
- except OSError:
113
- pass
114
- if freed:
115
- print(f' [shots] PASS 清理过程帧 {name}: -{freed // 1024}KB')
116
-
117
-
118
- def run_one(d, mod_path, name):
119
- mod = load_case(mod_path)
120
- t0 = time.time()
121
- try:
122
- if hasattr(mod, 'process_case'):
123
- sampler = None
124
- _sample_state = None
125
- try:
126
- os.environ['FORGE_CASE'] = name
127
- if SHOT_DIR: # 过程采样:每 2.5s 一帧(<case>_pNN.jpeg);连续相同帧去重(静态页 8 帧→1 帧)
128
- import threading
129
- state = {'n': 0, 'stop': False, 'last': None}
130
- _sample_state = state
131
-
132
- def _sample():
133
- import time as _t
134
- interval = 3.5 if SHOT_MODE == 'fail' else 2.5 # fail 模式降频,减少 RPC 争抢
135
- while not state['stop']:
136
- _t.sleep(interval)
137
- if state['stop']:
138
- break
139
- try:
140
- p = os.path.join(SHOT_DIR, f"{name}_p{state['n'] + 1:02d}.jpeg")
141
- _shot(d, p, f'proc{state["n"] + 1:02d}')
142
- dg = _file_digest(p)
143
- if dg is not None and dg == state['last']:
144
- os.remove(p) # 与上一帧相同(页面静止)→ 不留不计数
145
- else:
146
- state['last'] = dg
147
- state['n'] += 1
148
- except Exception:
149
- pass
150
- sampler = threading.Thread(target=_sample, daemon=True)
151
- sampler.start()
152
- mod.process_case(d)
153
- if SHOT_DIR:
154
- _shot(d, os.path.join(SHOT_DIR, name + '_end.jpeg'), 'end-shot')
155
- except Exception as _ex:
156
- if SHOT_DIR:
157
- _shot(d, os.path.join(SHOT_DIR, name + '_fail.jpeg'), 'fail-shot')
158
- raise type(_ex)(str(_ex) + _scene_evidence(d)) from None
159
- finally:
160
- _sample_state and _sample_state.__setitem__('stop', True)
161
- os.environ.pop('FORGE_CASE', None)
162
- else: # devicetest 风格兜底:实例化伪 TestCase
163
- cls = None
164
- for attr in dir(mod):
165
- obj = getattr(mod, attr)
166
- if isinstance(obj, type) and hasattr(obj, 'process') and attr != 'TestCase':
167
- cls = obj
168
- break
169
- if cls is None:
170
- return name, 'SKIP', 'no process func', 0
171
-
172
- # devicetest TestCase.__init__(TAG, controllers) —— 直跑时跳过基类构造
173
- inst = object.__new__(cls)
174
- inst.driver = d
175
- inst.device1 = d.device if hasattr(d, 'device') else None
176
- inst.TAG = name
177
- # 直跑跳过 __init__:软断言收集器等实例属性需补默认值
178
- if hasattr(cls, '_soft_check') and not hasattr(inst, '_errors'):
179
- inst._errors = []
180
- # setup 也在 try/finally 内:setup 抛异常时 teardown 仍执行
181
- try:
182
- if hasattr(cls, 'setup'):
183
- cls.setup(inst)
184
- try:
185
- os.environ['FORGE_CASE'] = name # 分步截图命名用
186
- cls.process(inst)
187
- if SHOT_DIR:
188
- _shot(d, os.path.join(SHOT_DIR, f'{name}_end.jpeg'), 'end-shot')
189
- except Exception:
190
- if SHOT_DIR:
191
- _shot(d, os.path.join(SHOT_DIR, f'{name}_fail.jpeg'), 'fail-shot')
192
- raise
193
- finally:
194
- os.environ.pop('FORGE_CASE', None)
195
- if hasattr(cls, 'teardown'):
196
- try:
197
- cls.teardown(inst)
198
- except Exception:
199
- pass
200
- return name, 'PASS', '', round(time.time() - t0, 1)
201
- except Exception as e:
202
- tb = traceback.format_exc().strip().split('\n')
203
- msg = f'{e} | {tb[-1][:120] if len(tb) > 1 else ""}'
204
- return name, 'FAIL', msg, round(time.time() - t0, 1)
205
-
206
-
207
- SHOT_DIR = None # 本次运行的截图目录(main 里初始化;None=不截图)
208
- SHOT_MODE = 'fail' # fail: PASS 只留 end 帧 | all: 全留 | off: 不截
209
-
210
- # TD-LADDER v2 预算:同一修复周期内允许的"显式全量"(--all/--acceptance) 次数;超限须 --force-all。
211
- # 目的:堵住 --acceptance 被当日常全量跑(Grit 实证:13 次 acceptance 全量 157min,占测试时间 65%)。
212
- LADDER_FULL_BUDGET = int(os.environ.get('FORGE_FULL_BUDGET', '2'))
213
-
214
-
215
- def _init_shot_dir():
216
- global SHOT_DIR
217
- if SHOT_MODE == 'off':
218
- os.environ.pop('FORGE_SHOT_DIR', None) # step_shot 由此禁用
219
- return
220
- import datetime
221
- SHOT_DIR = os.path.join('reports', datetime.datetime.now().strftime('%Y-%m-%d'),
222
- datetime.datetime.now().strftime('%H-%M-%S'))
223
- os.makedirs(SHOT_DIR, exist_ok=True)
224
- os.environ['FORGE_SHOT_DIR'] = SHOT_DIR # 分步截图(step_shot)读取
225
-
226
-
227
- def _prune_reports(keep, acceptance_keep=2):
228
- """截图留存策略:中间(非验收)运行目录即用即弃,验收目录永久保护。
229
-
230
- - 当前运行目录(SHOT_DIR)永不清
231
- - 带 .acceptance 标记的验收目录:保留最近 acceptance_keep 次(全程截图证据)
232
- - 其余中间运行目录:保留最近 keep 次(默认 0=即用即弃,agent 当轮取证后即回收)"""
233
- import glob as _g
234
- runs = sorted(_g.glob(os.path.join('reports', '*', '*')),
235
- key=lambda p: os.path.getmtime(p) if os.path.isdir(p) else 0, reverse=True)
236
- runs = [r for r in runs if os.path.isdir(r)]
237
- pinned = [r for r in runs if os.path.exists(os.path.join(r, '.acceptance'))]
238
- regular = [r for r in runs if r not in pinned]
239
- doomed = [r for r in regular[keep:] if os.path.abspath(r) != os.path.abspath(SHOT_DIR or '')]
240
- doomed += pinned[acceptance_keep:]
241
- freed = 0
242
- for p in doomed:
243
- for dirpath, _dirs, files in os.walk(p):
244
- for fn in files:
245
- try:
246
- freed += os.path.getsize(os.path.join(dirpath, fn))
247
- except OSError:
248
- pass
249
- import shutil as _sh
250
- _sh.rmtree(p, ignore_errors=True)
251
- for dp in _g.glob(os.path.join('reports', '*')):
252
- if os.path.isdir(dp) and not os.listdir(dp):
253
- os.rmdir(dp)
254
- if doomed:
255
- print(f'[retention] 中间运行即用即弃: 清理 {len(doomed)} 个旧目录 -{freed // 1048576}MB'
256
- f'(验收目录保留 {len(pinned[:acceptance_keep])} 个)')
257
-
258
-
259
- # ---------------- 产品代码变更检测(增量重试回归门禁) ----------------
260
- # 背景:--retry-failed 只重跑 FAIL/SKIP,PASS 保留原结果;若上一轮之后修改了产品代码
261
- # (entry/src/main/ets),旧 PASS 可能已回归(实证:Profiles TC09/TC14 PASS→FAIL 靠全量重跑才抓到)。
262
- # 门禁:结果侧车 results.meta.json 记录 srcHash;增量重试前重算,不一致 → 自动升级为全量重跑。
263
-
264
- def _find_src_root():
265
- """定位被测工程 entry/src/main/ets:FORGE_SRC_DIR 优先;否则在父/祖父目录的
266
- *Ohos|ohos 兄弟工程中,按 forge 目录名令牌匹配(timer-hypium→TimerOhos);
267
- 匹配失败且候选不唯一时返回 None(门禁将保守全量,宁可多跑不漏回归)。"""
268
- env = os.environ.get('FORGE_SRC_DIR')
269
- if env and os.path.isdir(env):
270
- return env
271
- import re as _re
272
- here = os.path.abspath(os.getcwd())
273
-
274
- def _norm(s):
275
- s = s.lower()
276
- changed = True
277
- while changed:
278
- changed = False
279
- for suf in ('-ohos', 'ohos', '-hypium', 'hypium', '-forge', 'forge', '-migration', 'migration'):
280
- if s.endswith(suf) and len(s) > len(suf):
281
- s = s[:-len(suf)]
282
- changed = True
283
- return s
284
-
285
- token = _norm(_re.split(r'[-_.]', os.path.basename(here))[0])
286
- if token in ('hypium', 'tmp', 'testcases', 'forge', ''): # <X>-migration/hypium_forge → 用父目录令牌
287
- token = _norm(_re.split(r'[-_.]', os.path.basename(os.path.dirname(here)))[0])
288
- cands = [] # (src_root, 归一化工程名)
289
- for base in (os.path.dirname(here), os.path.dirname(os.path.dirname(here))):
290
- if not base or not os.path.isdir(base):
291
- continue
292
- for d in sorted(os.listdir(base)):
293
- if d.endswith('Ohos') or d == 'ohos':
294
- cand = os.path.join(base, d, 'entry', 'src', 'main', 'ets')
295
- if os.path.isdir(cand):
296
- cands.append((cand, _norm(d)))
297
- if not cands:
298
- return None
299
- if len(cands) == 1:
300
- return cands[0][0]
301
- for cand, dn in cands:
302
- if dn == token or dn.startswith(token) or token.startswith(dn):
303
- return cand
304
- return None
305
-
306
-
307
- def src_tree_hash():
308
- """entry/src/main/ets 下全部 .ets/.ts 内容哈希(排序后串联),产品代码指纹。"""
309
- root = _find_src_root()
310
- if not root:
311
- return None
312
- h = hashlib.sha256()
313
- for dirpath, dirs, files in os.walk(root):
314
- dirs[:] = sorted(d for d in dirs if d not in ('build', 'oh_modules', '.preview'))
315
- for fn in sorted(files):
316
- if not fn.endswith(('.ets', '.ts')):
317
- continue
318
- p = os.path.join(dirpath, fn)
319
- h.update(os.path.relpath(p, root).encode())
320
- try:
321
- h.update(open(p, 'rb').read())
322
- except OSError:
323
- return None
324
- return h.hexdigest()[:16]
325
-
326
-
327
- def src_file_hashes():
328
- """每个 .ets/.ts 文件内容哈希 {相对路径: hash}——范围回归的变更定位基础。"""
329
- root = _find_src_root()
330
- if not root:
331
- return None
332
- out = {}
333
- for dirpath, dirs, files in os.walk(root):
334
- dirs[:] = sorted(d for d in dirs if d not in ('build', 'oh_modules', '.preview'))
335
- for fn in sorted(files):
336
- if not fn.endswith(('.ets', '.ts')):
337
- continue
338
- p = os.path.join(dirpath, fn)
339
- try:
340
- out[os.path.relpath(p, root)] = hashlib.sha1(open(p, 'rb').read()).hexdigest()[:12]
341
- except OSError:
342
- return None
343
- return out
344
-
345
-
346
- def _meta_path(result_path):
347
- return os.path.join(os.path.dirname(os.path.abspath(result_path)) or '.',
348
- 'results.meta.json')
349
-
350
-
351
- def _read_prev_meta(result_path):
352
- mp = _meta_path(result_path)
353
- if os.path.exists(mp):
354
- try:
355
- return json.load(open(mp, encoding='utf-8'))
356
- except Exception:
357
- return {}
358
- return {}
359
-
360
-
361
- def _read_prev_src_hash(result_path):
362
- return _read_prev_meta(result_path).get('srcHash')
363
-
364
-
365
- def _update_ladder_meta(result_path, last_full_hash=None, explicit_full=False, all_pass=False, mode=None):
366
- """TD-LADDER v2 预算状态(与 srcHash 记录共存于 results.meta.json):
367
- - last_full_hash: 最近一次"全量"运行的源码指纹(供"源码未变禁重复全量"判定);
368
- - explicit_full: 本轮是否显式全量(--all/--acceptance,不含 --retry-failed 自动升级)→ 计次;
369
- - all_pass: 本轮零失败 → 计次归零,开启新修复周期。"""
370
- try:
371
- mp = _meta_path(result_path)
372
- meta = {}
373
- if os.path.exists(mp):
374
- try:
375
- meta = json.load(open(mp, encoding='utf-8'))
376
- except Exception:
377
- meta = {}
378
- if last_full_hash:
379
- meta['lastFullSrcHash'] = last_full_hash
380
- meta['lastFullTs'] = time.time()
381
- if mode:
382
- meta['lastMode'] = mode
383
- if all_pass:
384
- meta['explicitFullCount'] = 0
385
- elif explicit_full:
386
- meta['explicitFullCount'] = int(meta.get('explicitFullCount') or 0) + 1
387
- json.dump(meta, open(mp, 'w', encoding='utf-8'), indent=1)
388
- except Exception:
389
- pass
390
-
391
-
392
- def _write_src_meta(result_path, src_hash, mode, files=None):
393
- if not src_hash:
394
- return
395
- try:
396
- mp = _meta_path(result_path)
397
- meta = {'srcHash': src_hash, 'ts': time.time(), 'mode': mode}
398
- if files:
399
- meta['files'] = files # 供下轮范围回归 diff(旧 meta 无此键 → 保守全量)
400
- json.dump(meta, open(mp, 'w', encoding='utf-8'), indent=1)
401
- except Exception:
402
- pass
403
-
404
-
405
- # ---------------- 范围回归(touched scope):改动定位 → 受影响用例子集 ----------------
406
- # 门禁升级不再非黑即白:产品代码变更时,若改动文件全部是"用例可定位的 UI 文件"
407
- # (ids.json 键前缀),则只重跑 FAIL/SKIP + 引用了这些文件的用例;其余 PASS 保留。
408
- # 任一改动文件是共享逻辑(viewmodel/repository/model)或不可定位 → 保守全量。
409
- # 引用识别不到依赖(纯文本交互)的 PASS 用例保守并入范围;范围超 60% 直接全量。
410
-
411
- _STR_LIT = re.compile(r"""['"]([A-Za-z_][A-Za-z0-9_]{2,})['"]""")
412
-
413
-
414
- def _load_id_file_map(ids_path):
415
- """ids.json(preinject 产物){'File:locator': id} → {id: 文件基名(lower)}。"""
416
- try:
417
- m = json.load(open(ids_path, encoding='utf-8'))
418
- return {str(v): str(k).split(':')[0].lower() for k, v in m.items()}
419
- except Exception:
420
- return None
421
-
422
-
423
- def case_dep_files(path, id2file):
424
- """用例源码中引用的 id 字面量 → 依赖 UI 文件基名集合(空集=纯文本交互,无法定位)。"""
425
- try:
426
- src = open(path, encoding='utf-8').read()
427
- except Exception:
428
- return None
429
- deps = set()
430
- for lit in _STR_LIT.findall(src):
431
- f = id2file.get(lit)
432
- if f:
433
- deps.add(f)
434
- return deps
435
-
436
-
437
- def plan_touched_scope(changed_rel, id2file, dir_):
438
- """返回受影响 UI 文件基名集合;改动越界(非 UI 文件)返回 None=保守全量。"""
439
- changed_bases = {os.path.basename(c).rsplit('.', 1)[0].lower() for c in changed_rel}
440
- ui_bases = set(id2file.values())
441
- if not changed_bases <= ui_bases:
442
- return None
443
- return changed_bases
444
-
445
-
446
-
447
- HDC_BIN = '/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc'
448
- DEVICE_SN = None # main() 里从 args.device 赋值
449
-
450
-
451
- def _shot(d, path, label='shot'):
452
- """截图(尽力而为,绝不影响结果记录):d.screenshot 优先,失败走 hdc snapshot_display。"""
453
- os.makedirs(os.path.dirname(path) if os.path.dirname(path) else '.', exist_ok=True)
454
- try:
455
- d.screenshot(path)
456
- print(f' [{label}] {path}')
457
- return
458
- except Exception:
459
- pass
460
- try:
461
- import subprocess as _sp
462
- dev = DEVICE_SN or os.environ.get('FORGE_DEVICE', '127.0.0.1:5555')
463
- remote = f'/data/local/tmp/_forge_shot.jpeg'
464
- _sp.run([HDC_BIN, '-t', dev, 'shell', 'snapshot_display', '-f', remote],
465
- capture_output=True, timeout=10)
466
- _sp.run([HDC_BIN, '-t', dev, 'file', 'recv', remote, path],
467
- capture_output=True, timeout=15)
468
- _sp.run([HDC_BIN, '-t', dev, 'shell', 'rm', '-f', remote],
469
- capture_output=True, timeout=5)
470
- print(f' [{label}] {path} (hdc)')
471
- except Exception as exc:
472
- print(f' [{label}] 不可用: {exc}')
473
-
474
-
475
- def main():
476
- ap = argparse.ArgumentParser()
477
- ap.add_argument('--case', default=None)
478
- ap.add_argument('--all', action='store_true')
479
- ap.add_argument('--dir', default='testcases')
480
- ap.add_argument('--device', default='127.0.0.1:5555')
481
- ap.add_argument('--retry-failed', action='store_true',
482
- help='增量重试:只重跑 results.json 中 FAIL/SKIP 条(PASS 保留原结果,合并写出);'
483
- '产品代码(entry/src/main/ets)较上轮有变更时自动升级为全量重跑')
484
- ap.add_argument('--force-incremental', action='store_true',
485
- help='跳过产品代码变更门禁,强制增量(仅测试脚本变更时使用)')
486
- ap.add_argument('--regression-scope', choices=('touched', 'full'), default=None,
487
- help='产品代码变更时的回归范围: touched=FAIL/SKIP+引用改动UI文件的用例(默认) | full=全量'
488
- '(env FORGE_REGRESSION_SCOPE 同义;改动含共享逻辑文件时 touched 自动退化为 full)')
489
- ap.add_argument('--cold', action='store_true',
490
- help='--case 单条验证时强制冷启(默认热启动软复用,省 8-12s;断言冷启行为时用)')
491
- ap.add_argument('--case-timeout', type=int, default=240,
492
- help='单用例看门狗秒数(默认 240,超时记 FAIL 继续下一条,防挂死烧设备时间;0=关闭)')
493
- ap.add_argument('--flake-retry', type=int, default=1,
494
- help='瞬态失败(watchdog/连接类)轮末自动复测次数(默认 1,省一轮 agent 往返;0=关闭)')
495
- ap.add_argument('--filter', default=None,
496
- help='子串过滤用例文件名(保留分组序),如 --filter TC3 跑 TC30/TC31/TC35')
497
- ap.add_argument('--shots', choices=('fail', 'all', 'off'), default=None,
498
- help='截图策略: fail=PASS只留end帧/FAIL留全过程(默认) | all=全程留存 | off=不截')
499
- ap.add_argument('--keep-reports', type=int, default=None,
500
- help='中间(非验收)运行目录保留最近 N 份(默认 0=即用即弃,当轮取证后即回收;'
501
- 'env FORGE_KEEP_REPORTS)。验收目录(.acceptance)不受此限制,独立保留最近 2 次')
502
- ap.add_argument('--force-all', action='store_true',
503
- help='跳过修复轮 --all 防护(仅限确有必要的全量场景;正常流程: --case 单条 → '
504
- '--retry-failed 增量 → --acceptance 终局验收)')
505
- ap.add_argument('--acceptance', action='store_true',
506
- help='验收模式:强制全量 + 全程截图(--shots all,显式传 --shots 时以显式为准),'
507
- '结果目录写入 .acceptance 标记——prune 永不清理最近的验收目录(保留最近 2 次),'
508
- '作为最终质量门的完整截图证据')
509
- args = ap.parse_args()
510
- # 阶梯防护 v2(TD-LADDER):所有"显式全量"(--all / --acceptance / 变相全量的 --filter)统一受管。
511
- # R1 上轮有 FAIL/SKIP → 必须先修(--case/--retry-failed),禁止全量;
512
- # R2/R2b 源码未变且已全量过 → 重复全量无新结论;--acceptance 仅当"上次也是同源验收"才拦;
513
- # R3 本周期显式全量已达上限 → 见 LADDER_FULL_BUDGET;
514
- # L2 零进展(连续两轮失败签名一致且源码未变)→ 拒绝重跑,必须先 probe 定案改码。
515
- # 免检:--retry-failed 因源码变更自动升级的全量(非显式,不占预算);--force-all 逃生。
516
- _prev_path = os.path.join(os.path.abspath(args.dir), '..', 'results.json')
517
- _prev_res, _meta = None, {}
518
- if os.path.exists(_prev_path):
519
- try:
520
- _prev_res = json.load(open(_prev_path, encoding='utf-8'))
521
- except Exception:
522
- _prev_res = None
523
- _meta = _read_prev_meta(_prev_path)
524
- _pending = [str(r.get('case', '?')) for r in (_prev_res or [])
525
- if r.get('status') in ('FAIL', 'SKIP')]
526
- _cur_hash = src_tree_hash()
527
- _last_full_hash = _meta.get('lastFullSrcHash')
528
- _full_count = int(_meta.get('explicitFullCount') or 0)
529
-
530
- # L1:--filter 覆盖超半数用例 = 变相全量,按显式全量纳管
531
- _filter_full = False
532
- if args.filter:
533
- try:
534
- _all_files = order_files(args.dir)
535
- _hit = [f for f, _g in _all_files if args.filter in f]
536
- _filter_full = bool(_all_files) and len(_hit) > max(5, int(len(_all_files) * 0.25))
537
- if _filter_full:
538
- print(f'ℹ --filter "{args.filter}" 覆盖 {len(_hit)}/{len(_all_files)} 条(>25% 且 >5)= 变相全量,按全量纳管')
539
- except Exception:
540
- _filter_full = False
541
-
542
- def _ladder_block(reason):
543
- print('⛔ 阶梯防护:拒绝显式全量(--all / --acceptance / 变相 --filter)')
544
- print(f' 原因:{reason}')
545
- print(' ▶ 单条验证: --case <失败用例>')
546
- print(' ▶ 增量重跑: --retry-failed(源码变更时按 srcHash 自动收窄/升级)')
547
- print(' ▶ 全绿后再做 终局验收: --acceptance(证据留档,全程截图)')
548
- print(' ▶ 确有必要的一次性全量: --force-all(跳过防护,自担"无进展"风险)')
549
- sys.exit(2)
550
-
551
- explicit_full = (args.all or args.acceptance or _filter_full) and not args.force_all
552
- if explicit_full:
553
- _reason = None
554
- if _pending:
555
- _reason = (f'上轮 results.json 尚有 {len(_pending)} 条 FAIL/SKIP:'
556
- + '、'.join(_pending[:6]) + (' …' if len(_pending) > 6 else ''))
557
- elif (not args.acceptance) and _cur_hash and _last_full_hash == _cur_hash:
558
- _reason = '源码自上次全量以来未变化(srcHash 相同),重复全量不会得到新结论'
559
- elif (args.acceptance and _meta.get('lastMode') == 'acceptance'
560
- and _cur_hash and _last_full_hash == _cur_hash):
561
- _reason = '上次已是同源验收(srcHash 相同),无需重复验收'
562
- elif _full_count >= LADDER_FULL_BUDGET:
563
- _reason = f'本修复周期显式全量已达 {_full_count} 次(上限 {LADDER_FULL_BUDGET})'
564
- if _reason:
565
- _ladder_block(_reason)
566
-
567
- # L2:零进展硬阻断——连续两轮失败签名一致且源码未变时,重跑(增量/全量)必然同样失败。
568
- if not args.force_all and not args.case and _cur_hash and _pending \
569
- and _meta.get('srcHash') == _cur_hash:
570
- _prev_prev = os.path.join(os.path.abspath(args.dir), '..', 'results.prev.json')
571
- if os.path.exists(_prev_prev):
572
- try:
573
- _pp = json.load(open(_prev_prev, encoding='utf-8'))
574
-
575
- def _failsig(lst):
576
- return sorted({(r.get('msg') or '').split(' | ')[0][:60]
577
- for r in lst if r.get('status') == 'FAIL'})
578
-
579
- if _failsig(_pp) and _failsig(_pp) == _failsig(_prev_res or []):
580
- print('⛔ 阶梯防护(L2 零进展):连续两轮失败签名集完全一致,且源码未变化。')
581
- print(' 重复重跑必然同样失败——请先用 probe.py(tree/hilog/db-pull)定案并修改代码。')
582
- print(' 确需强跑: --force-all')
583
- sys.exit(2)
584
- except Exception:
585
- pass
586
- global DEVICE_SN, SHOT_MODE
587
- DEVICE_SN = args.device
588
- SHOT_MODE = args.shots or os.environ.get('FORGE_SHOT_MODE', 'fail')
589
- if args.acceptance:
590
- args.all = True # 验收=强制全量
591
- if not args.shots:
592
- SHOT_MODE = 'all' # 验收全程截图(显式 --shots 优先)
593
- print('═══ 验收模式: 全量 + 全程截图 + .acceptance 标记(prune 保护) ═══')
594
- if SHOT_MODE not in ('fail', 'all', 'off'):
595
- SHOT_MODE = 'fail'
596
- os.environ['FORGE_SHOT_MODE'] = SHOT_MODE # step_shot(_common) 同步感知 off
597
- keep_reports = args.keep_reports if args.keep_reports is not None else int(
598
- os.environ.get('FORGE_KEEP_REPORTS', '0') or 0)
599
- sys.path.insert(0, os.path.abspath(args.dir))
600
-
601
- if args.case:
602
- ordered = [(f'{args.case}.py', 'anonymous')]
603
- elif args.retry_failed:
604
- prev = os.path.join(os.path.abspath(args.dir), '..', 'results.json')
605
- if not os.path.exists(prev):
606
- print('--retry-failed 需要 results.json'); sys.exit(1)
607
- # ---- 回归门禁:产品代码变更 → 全量 或 范围回归(touched,TD-019+)----
608
- cur_hash = src_tree_hash()
609
- if not args.force_incremental:
610
- prev_hash = _read_prev_src_hash(prev)
611
- if cur_hash is None:
612
- print('⚠ 未定位到 entry/src/main/ets(可用 FORGE_SRC_DIR 指定),无法判定产品代码变更 → 保守全量重跑')
613
- args.all = True
614
- elif prev_hash is None:
615
- print('⚠ results.meta.json 无上轮 srcHash 记录(旧版产物)→ 保守全量重跑(本轮后建立指纹)')
616
- args.all = True
617
- elif cur_hash != prev_hash:
618
- scope_mode = (args.regression_scope
619
- or os.environ.get('FORGE_REGRESSION_SCOPE') or 'touched')
620
- touched = None
621
- if scope_mode == 'touched':
622
- prev_files = _read_prev_meta(prev).get('files')
623
- cur_files = src_file_hashes()
624
- if prev_files and cur_files:
625
- changed_rel = {r for r in cur_files if prev_files.get(r) != cur_files[r]}
626
- changed_rel |= set(prev_files) - set(cur_files) # 删除/改名也算
627
- ids_path = os.path.join(os.path.dirname(os.path.abspath(prev)), 'ids.json')
628
- id2file = _load_id_file_map(ids_path)
629
- if id2file is not None and changed_rel:
630
- touched = plan_touched_scope(changed_rel, id2file, args.dir)
631
- globals()['_ID2FILE'] = id2file
632
- if touched:
633
- print(f'⚡ 产品代码变更限于 UI 文件 {sorted(touched)} → 范围回归: '
634
- f'FAIL/SKIP + 引用这些文件的用例(其余 PASS 保留)')
635
- globals()['_SCOPE_BASES'] = touched
636
- else:
637
- print(f'⚠ 产品代码已变更(srcHash {prev_hash} → {cur_hash}):改动含共享/不可定位文件 '
638
- f'→ 自动升级为全量重跑(改单页时可检查 ids.json 覆盖;强制收窄 --regression-scope touched)')
639
- args.all = True
640
- prev_res = {str(r.get('case')).split('_')[0]: r
641
- for r in json.load(open(prev, encoding='utf-8'))}
642
- all_ordered = order_files(args.dir)
643
- if args.all: # 门禁升级为全量:不保留旧 PASS
644
- ordered = all_ordered
645
- print(f'全量重跑(产品代码变更门禁): {len(ordered)} 条')
646
- else:
647
- ordered = [(f, g) for f, g in all_ordered
648
- if f[:-3].split('_')[0] not in prev_res
649
- or prev_res[f[:-3].split('_')[0]].get('status') != 'PASS']
650
- scope_bases = globals().get('_SCOPE_BASES')
651
- if scope_bases: # touched:并入"引用改动文件的 PASS 用例"(无依赖的保守并入)
652
- id2file = globals().get('_ID2FILE') or {}
653
- extra, ambiguous = [], 0
654
- for f, g in all_ordered:
655
- cid = f[:-3].split('_')[0]
656
- if cid in prev_res and prev_res[cid].get('status') == 'PASS':
657
- deps = case_dep_files(os.path.join(args.dir, f), id2file)
658
- if deps is None or not deps or (deps & scope_bases):
659
- if deps is None or not deps:
660
- ambiguous += 1
661
- extra.append((f, g))
662
- if len(ordered) + len(extra) > max(3, int(len(all_ordered) * 0.6)):
663
- print(f'⚠ 范围回归覆盖 {len(ordered) + len(extra)}/{len(all_ordered)} 条(含无依赖 {ambiguous})'
664
- f'→ 超过 60% 阈值,升级全量')
665
- ordered = all_ordered
666
- args.all = True
667
- else:
668
- ordered = ordered + extra
669
- ordered.sort(key=lambda x: (GROUP_ORDER[x[1]], x[0]))
670
- print(f'范围回归: FAIL/SKIP+受影响 {len(ordered)} 条(含无依赖纯文本用例 {ambiguous},保守并入)')
671
- keep = [prev_res[f[:-3].split('_')[0]] for f, g in all_ordered
672
- if f[:-3].split('_')[0] in prev_res
673
- and prev_res[f[:-3].split('_')[0]].get('status') == 'PASS'
674
- and (f, g) not in ordered]
675
- print(f'增量重试: 重跑 {len(ordered)} 条 / 保留 PASS {len(keep)} 条')
676
- globals()['_RETRY_KEEP'] = keep
677
- elif args.all or args.filter:
678
- ordered = order_files(args.dir)
679
- if args.filter:
680
- ordered = [(f, g) for f, g in ordered if args.filter in f]
681
- if not ordered:
682
- print(f'--filter "{args.filter}" 无匹配用例'); sys.exit(1)
683
- else:
684
- print('指定 --case X / --all / --retry-failed / --filter'); sys.exit(1)
685
-
686
- _init_shot_dir()
687
- os.environ['FORGE_DEVICE'] = args.device # launch(want/reset) 的 hdc 透传用
688
-
689
- d = UiDriver.connect(device_sn=args.device)
690
- import signal
691
-
692
- def _watchdog_arm(sec):
693
- if sec and hasattr(signal, 'SIGALRM'):
694
- def _handler(signum, frame):
695
- raise TimeoutError(f'case watchdog {sec}s')
696
- signal.signal(signal.SIGALRM, _handler)
697
- signal.alarm(sec)
698
-
699
- def _watchdog_disarm():
700
- if hasattr(signal, 'SIGALRM'):
701
- signal.alarm(0)
702
-
703
- results = []
704
- cur_group = None
705
- first_in_group = True
706
- for f, grp in ordered:
707
- name = f[:-3]
708
- if grp != cur_group: # 组切换:首启组排尾,此前组状态不被回头破坏
709
- cur_group = grp
710
- first_in_group = True
711
- print(f'\n────── 前置组 [{grp}] 开始({grp} 组内软隔离共享前置)──────', flush=True)
712
- os.environ['FORGE_GROUP_FIRST'] = '1'
713
- # 热启动:组内非首条且锚已在位时 launch() 软复用当前会话(省冷启 8-12s/例);
714
- # first_launch 组(bm clean)与组首条(冷启验证)保持冷启;
715
- # --case 单条验证默认热启动(--cold 强制冷启)
716
- warm = (grp in ('anonymous', 'logged_in') and not first_in_group) or \
717
- (args.case and not args.cold and grp != 'first_launch')
718
- if warm:
719
- os.environ['FORGE_WARM_LAUNCH'] = '1'
720
- else:
721
- os.environ.pop('FORGE_WARM_LAUNCH', None)
722
- print(f'▶ {name} ...', flush=True)
723
- _watchdog_arm(args.case_timeout)
724
- try:
725
- n, status, msg, dur = run_one(d, os.path.join(args.dir, f), name)
726
- except TimeoutError as te:
727
- n, status, msg, dur = name, 'FAIL', f'{te}(挂死截断,已跳下一条)', round(time.time(), 0)
728
- finally:
729
- _watchdog_disarm()
730
- os.environ.pop('FORGE_GROUP_FIRST', None)
731
- first_in_group = False
732
- print(f' {status} ({dur}s) {msg[:110]}', flush=True)
733
- if status == 'PASS' and SHOT_MODE == 'fail':
734
- _clean_pass_shots(name) # PASS 只留 end 帧,FAIL 保留全过程证据
735
- results.append({'case': n, 'status': status, 'msg': msg, 'dur': dur, 'group': grp})
736
- time.sleep(0.5) # launch 自带落座等待,长睡交给用例前置
737
-
738
- # ---- 瞬态失败轮末复测:watchdog/连接类抖动当场再跑一次,PASS 记 flake-retry ----
739
- # (省一轮"agent 读结果→重跑全量"往返;断言类失败不自动复测,防掩盖真缺陷)
740
- if args.flake_retry > 0:
741
- TRANSIENT = re.compile(r'watchdog|connector|Fail to get device|device disconnect|TimeoutError', re.I)
742
- for r in list(results):
743
- if r['status'] == 'FAIL' and TRANSIENT.search(r.get('msg', '')):
744
- print(f'▶ {r["case"]} 瞬态失败特征,轮末复测...', flush=True)
745
- os.environ.pop('FORGE_WARM_LAUNCH', None) # 复测走完整冷启(连接类抖动需重连)
746
- _watchdog_arm(args.case_timeout)
747
- try:
748
- n2, st2, msg2, dur2 = run_one(d, os.path.join(args.dir, r['case'] + '.py'), r['case'])
749
- except TimeoutError as te:
750
- n2, st2, msg2, dur2 = r['case'], 'FAIL', str(te), 0
751
- finally:
752
- _watchdog_disarm()
753
- if st2 == 'PASS':
754
- r.update({'status': 'PASS',
755
- 'msg': f'flake-retry 二跑 PASS(首跑: {r["msg"][:60]})',
756
- 'dur': round(r['dur'] + dur2, 1)})
757
- print(f' PASS(2nd) {r["case"]}', flush=True)
758
- else:
759
- r['msg'] = f'{r["msg"]} | 复测仍失败: {msg2[:60]}'
760
-
761
- results = globals().get('_RETRY_KEEP', []) + results
762
- p = sum(1 for r in results if r['status'] == 'PASS')
763
- f_ = sum(1 for r in results if r['status'] == 'FAIL')
764
- print(f'\n══════ 直跑汇总: PASS={p} FAIL={f_} SKIP={len(results)-p-f_} / {len(results)} ══════')
765
- from collections import Counter
766
- print('分组: ' + ', '.join(f'{g}={c}' for g, c in Counter(r['group'] for r in results).items()))
767
- # 同族错误聚类(分诊三律①:同消息视为同族,一次定案禁逐条实验)
768
- fails = [r for r in results if r['status'] == 'FAIL']
769
- if fails:
770
- import re as _re
771
- def _sig(m):
772
- head = (m or '').split(' | ')[0]
773
- return _re.sub(r'[::].+', ': X', head)[:60]
774
- clus = Counter(_sig(r.get('msg')) for r in fails)
775
- print('同族聚类: ' + ' ; '.join(f'{c}×[{k}]' for k, c in clus.most_common(4)))
776
- out = 'results.json' if (args.all or args.retry_failed) else 'results-%s.json' % (args.case or 'single').split('_')[0]
777
- # ---- 零进展检测:失败签名集与上一轮完全一致 → 修复未生效,重跑必复现 ----
778
- # (Grit 实证泥潭:taskSaveButton 同族失败连卡 5 轮全量 71min。触发即亮牌,
779
- # agent 应停止重跑、转 probe.py/hilog 定案——分诊三律② 同族定案实验 ≤2 次)
780
- if fails and os.path.exists(out):
781
- try:
782
- prev_list = json.load(open(out, encoding='utf-8'))
783
-
784
- def _sigset(lst):
785
- from collections import Counter as _C
786
- return _C(re.sub(r'[::].+', '', (r.get('msg') or '')).split(' | ')[0][:60]
787
- for r in lst if r.get('status') == 'FAIL')
788
-
789
- if _sigset(prev_list) and _sigset(prev_list) == _sigset(results):
790
- print('\n⚠⚠ 零进展警告: 失败签名集与上一轮完全一致('
791
- + ', '.join(f'{c}×{k}' for k, c in _sigset(results).most_common(3))
792
- + ')——修复未生效,重复重跑必然同样失败。'
793
- '停止重跑,用 probe.py(tree/hilog/db-pull)定案后改代码,再回来跑。')
794
- except Exception:
795
- pass
796
- if os.path.exists(out): # 修复轮重跑前保留上一轮原始记录(自愈边界:修≠覆盖历史)
797
- import shutil as _sh
798
- _sh.copy(out, out.replace('.json', '.prev.json'))
799
- json.dump(results, open(out, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
800
- if args.all or args.retry_failed: # 全量/合并结果写入后记录产品代码指纹(供下轮增量门禁/范围回归比对)
801
- _cur_src_hash = src_tree_hash()
802
- _mode = 'acceptance' if args.acceptance else ('full' if args.all else 'retry')
803
- _write_src_meta(out, _cur_src_hash, _mode, files=src_file_hashes())
804
- _update_ladder_meta(out, last_full_hash=(_cur_src_hash if args.all else None),
805
- explicit_full=explicit_full, all_pass=(f_ == 0), mode=_mode)
806
- if args.acceptance and SHOT_DIR: # 验收目录打标:prune 独立保护,全程截图永久留存
807
- try:
808
- json.dump({'ts': time.time(), 'total': len(results), 'pass': p, 'fail': f_,
809
- 'srcHash': _read_prev_src_hash(out) or ''},
810
- open(os.path.join(SHOT_DIR, '.acceptance'), 'w', encoding='utf-8'), indent=1)
811
- print(f'[acceptance] 验收证据目录已保护: {SHOT_DIR}(PASS={p} FAIL={f_})')
812
- except Exception:
813
- pass
814
- print('明细: ' + out)
815
- if fails and not args.retry_failed: # 阶梯重跑提示(防修复后无脑 --all 空转设备时间)
816
- first_fail = fails[0]['case']
817
- print(f'\n▶ 下一步(重跑阶梯,禁直接 --all): '
818
- f'先 `--case {first_fail}` 验证修复 → 再 `--retry-failed`(改过产品代码会自动升级全量)')
819
- _prune_reports(keep_reports)
820
-
821
-
822
- if __name__ == '__main__':
823
- main()
1
+ #!/usr/bin/env python3
2
+ """
3
+ run_direct.py — 裸 hypium 直跑器(无 devicetest/xdevice 依赖)+ 前置条件分组编排。
4
+
5
+ 编排规则(TD-004 修正:共用前置条件,防交错破坏):
6
+ 用例文件可声明模块级常量 PRECOND(缺省 'anonymous'):
7
+ - 'anonymous' 无状态要求(软启动即可)
8
+ - 'logged_in' 需登录态(组内软隔离保持 preferences,ensure_login 幂等复用)
9
+ - 'first_launch' 需首启态(bm clean 破坏性最大,强制排尾,不再破坏后续)
10
+ 执行顺序 = 组序(anonymous → logged_in → first_launch)→ 组内文件名序。
11
+ 组切换打印分隔;results.json 每条附 group 字段(可分组统计)。
12
+ 组首条置 os.environ['FORGE_GROUP_FIRST']='1'(用例可选做完整冷启验证),执行后清除。
13
+
14
+ 用法:
15
+ 单条: python3 run_direct.py --case F01_func_normal_001 [--dir testcases]
16
+ 批量: python3 run_direct.py --all [--dir testcases]
17
+ 子串: python3 run_direct.py --filter TC3 [--dir testcases] # 跑文件名含 TC3 的用例(保留分组序)
18
+
19
+ 截图与留存(防 reports/ 无限膨胀——Grit 实证 15 轮全量 5266 张/666MB):
20
+ --shots fail 默认。PASS 用例仅留 <case>_end.jpeg;过程帧(_pNN)/分步图 仅 FAIL 时保留;
21
+ 连续相同帧自动去重(静态页面 8 帧 → 1 帧)
22
+ --shots all 全程留存(需逐步回放页面时用)
23
+ --shots off 完全不截图(纯冒烟)
24
+ --acceptance 验收模式:强制全量 + 全程截图,目录写 .acceptance 标记独立保护(保留最近 2 次)
25
+ --keep-reports N 中间(非验收)运行目录保留最近 N 份(默认 0=即用即弃;env FORGE_KEEP_REPORTS)
26
+ env FORGE_SHOT_MODE 同 --shots(CLI 优先)
27
+
28
+ 要求: 用例 .py 文件含 process_case(d) 顶层函数;或兼容 devicetest 风格(反射 process 方法)。
29
+ 结果: 逐条打印 PASS/FAIL + 汇总,写 results.json
30
+ """
31
+ import argparse
32
+ import hashlib
33
+ import importlib.util
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+ import time
39
+ import traceback
40
+
41
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
42
+
43
+ from hypium import UiDriver
44
+
45
+ # 前置组执行序:破坏性递增(first_launch 会 bm clean,必须排尾)
46
+ GROUP_ORDER = {'anonymous': 0, 'logged_in': 1, 'first_launch': 2}
47
+
48
+
49
+ def load_case(path):
50
+ spec = importlib.util.spec_from_file_location('case_mod', path)
51
+ mod = importlib.util.module_from_spec(spec)
52
+ spec.loader.exec_module(mod)
53
+ return mod
54
+
55
+
56
+ def case_group(mod):
57
+ g = str(getattr(mod, 'PRECOND', 'anonymous') or 'anonymous')
58
+ return g if g in GROUP_ORDER else 'anonymous'
59
+
60
+
61
+ def order_files(dir_):
62
+ """返回 [(filename, group)],按 组序→文件名 排序;只 import 一次读 PRECOND。"""
63
+ files = sorted(f for f in os.listdir(dir_) if f.endswith('.py') and not f.startswith('_'))
64
+ tagged = []
65
+ for f in files:
66
+ try:
67
+ mod = load_case(os.path.join(dir_, f))
68
+ tagged.append((f, case_group(mod)))
69
+ except Exception:
70
+ tagged.append((f, 'anonymous')) # import 失败留给 run_one 报 FAIL,不在此拦截
71
+ tagged.sort(key=lambda x: (GROUP_ORDER[x[1]], x[0]))
72
+ return tagged
73
+
74
+
75
+ def _scene_evidence(d, n=8):
76
+ # FAIL 现场证据: 当前页面 Text 摘要(进 results.msg, 供下轮 LLM 直读省重取证)
77
+ try:
78
+ ts = [(c.getText() or '')[:12] for c in (d.find_all_components(BY.type('Text')) or [])]
79
+ ts = [t for t in ts if t][:n]
80
+ return ' | 现场Text: ' + '/'.join(ts) if ts else ' | 现场Text: (空)'
81
+ except Exception:
82
+ return ''
83
+
84
+
85
+ def _file_digest(path):
86
+ try:
87
+ return hashlib.sha1(open(path, 'rb').read()).hexdigest()
88
+ except Exception:
89
+ return None
90
+
91
+
92
+ def _clean_pass_shots(name):
93
+ """shots=fail 模式:PASS 用例只留 <case>_end.jpeg,删过程帧(_pNN)与分步图(_NN_label)。"""
94
+ if not SHOT_DIR:
95
+ return
96
+ import glob as _g
97
+ freed = 0
98
+ for pat in (os.path.join(SHOT_DIR, name + '_p*.jpeg'),
99
+ os.path.join(SHOT_DIR, name + '_fail.jpeg')):
100
+ for p in _g.glob(pat):
101
+ try:
102
+ freed += os.path.getsize(p)
103
+ os.remove(p)
104
+ except OSError:
105
+ pass
106
+ for p in _g.glob(os.path.join(SHOT_DIR, name + '_*.jpeg')):
107
+ base = os.path.basename(p)
108
+ if re.match(r'^%s_\d{2}_.*\.jpeg$' % re.escape(name), base): # step_shot 分步图
109
+ try:
110
+ freed += os.path.getsize(p)
111
+ os.remove(p)
112
+ except OSError:
113
+ pass
114
+ if freed:
115
+ print(f' [shots] PASS 清理过程帧 {name}: -{freed // 1024}KB')
116
+
117
+
118
+ def run_one(d, mod_path, name):
119
+ mod = load_case(mod_path)
120
+ t0 = time.time()
121
+ try:
122
+ if hasattr(mod, 'process_case'):
123
+ sampler = None
124
+ _sample_state = None
125
+ try:
126
+ os.environ['FORGE_CASE'] = name
127
+ if SHOT_DIR: # 过程采样:每 2.5s 一帧(<case>_pNN.jpeg);连续相同帧去重(静态页 8 帧→1 帧)
128
+ import threading
129
+ state = {'n': 0, 'stop': False, 'last': None}
130
+ _sample_state = state
131
+
132
+ def _sample():
133
+ import time as _t
134
+ interval = 3.5 if SHOT_MODE == 'fail' else 2.5 # fail 模式降频,减少 RPC 争抢
135
+ while not state['stop']:
136
+ _t.sleep(interval)
137
+ if state['stop']:
138
+ break
139
+ try:
140
+ p = os.path.join(SHOT_DIR, f"{name}_p{state['n'] + 1:02d}.jpeg")
141
+ _shot(d, p, f'proc{state["n"] + 1:02d}')
142
+ dg = _file_digest(p)
143
+ if dg is not None and dg == state['last']:
144
+ os.remove(p) # 与上一帧相同(页面静止)→ 不留不计数
145
+ else:
146
+ state['last'] = dg
147
+ state['n'] += 1
148
+ except Exception:
149
+ pass
150
+ sampler = threading.Thread(target=_sample, daemon=True)
151
+ sampler.start()
152
+ mod.process_case(d)
153
+ if SHOT_DIR:
154
+ _shot(d, os.path.join(SHOT_DIR, name + '_end.jpeg'), 'end-shot')
155
+ except Exception as _ex:
156
+ if SHOT_DIR:
157
+ _shot(d, os.path.join(SHOT_DIR, name + '_fail.jpeg'), 'fail-shot')
158
+ raise type(_ex)(str(_ex) + _scene_evidence(d)) from None
159
+ finally:
160
+ _sample_state and _sample_state.__setitem__('stop', True)
161
+ os.environ.pop('FORGE_CASE', None)
162
+ else: # devicetest 风格兜底:实例化伪 TestCase
163
+ cls = None
164
+ for attr in dir(mod):
165
+ obj = getattr(mod, attr)
166
+ if isinstance(obj, type) and hasattr(obj, 'process') and attr != 'TestCase':
167
+ cls = obj
168
+ break
169
+ if cls is None:
170
+ return name, 'SKIP', 'no process func', 0
171
+
172
+ # devicetest TestCase.__init__(TAG, controllers) —— 直跑时跳过基类构造
173
+ inst = object.__new__(cls)
174
+ inst.driver = d
175
+ inst.device1 = d.device if hasattr(d, 'device') else None
176
+ inst.TAG = name
177
+ # 直跑跳过 __init__:软断言收集器等实例属性需补默认值
178
+ if hasattr(cls, '_soft_check') and not hasattr(inst, '_errors'):
179
+ inst._errors = []
180
+ # setup 也在 try/finally 内:setup 抛异常时 teardown 仍执行
181
+ try:
182
+ if hasattr(cls, 'setup'):
183
+ cls.setup(inst)
184
+ try:
185
+ os.environ['FORGE_CASE'] = name # 分步截图命名用
186
+ cls.process(inst)
187
+ if SHOT_DIR:
188
+ _shot(d, os.path.join(SHOT_DIR, f'{name}_end.jpeg'), 'end-shot')
189
+ except Exception:
190
+ if SHOT_DIR:
191
+ _shot(d, os.path.join(SHOT_DIR, f'{name}_fail.jpeg'), 'fail-shot')
192
+ raise
193
+ finally:
194
+ os.environ.pop('FORGE_CASE', None)
195
+ if hasattr(cls, 'teardown'):
196
+ try:
197
+ cls.teardown(inst)
198
+ except Exception:
199
+ pass
200
+ return name, 'PASS', '', round(time.time() - t0, 1)
201
+ except Exception as e:
202
+ tb = traceback.format_exc().strip().split('\n')
203
+ msg = f'{e} | {tb[-1][:120] if len(tb) > 1 else ""}'
204
+ return name, 'FAIL', msg, round(time.time() - t0, 1)
205
+
206
+
207
+ SHOT_DIR = None # 本次运行的截图目录(main 里初始化;None=不截图)
208
+ SHOT_MODE = 'fail' # fail: PASS 只留 end 帧 | all: 全留 | off: 不截
209
+
210
+ # TD-LADDER v2 预算:同一修复周期内允许的"显式全量"(--all/--acceptance) 次数;超限须 --force-all。
211
+ # 目的:堵住 --acceptance 被当日常全量跑(Grit 实证:13 次 acceptance 全量 157min,占测试时间 65%)。
212
+ LADDER_FULL_BUDGET = int(os.environ.get('FORGE_FULL_BUDGET', '2'))
213
+
214
+
215
+ def _init_shot_dir():
216
+ global SHOT_DIR
217
+ if SHOT_MODE == 'off':
218
+ os.environ.pop('FORGE_SHOT_DIR', None) # step_shot 由此禁用
219
+ return
220
+ import datetime
221
+ SHOT_DIR = os.path.join('reports', datetime.datetime.now().strftime('%Y-%m-%d'),
222
+ datetime.datetime.now().strftime('%H-%M-%S'))
223
+ os.makedirs(SHOT_DIR, exist_ok=True)
224
+ os.environ['FORGE_SHOT_DIR'] = SHOT_DIR # 分步截图(step_shot)读取
225
+
226
+
227
+ def _prune_reports(keep, acceptance_keep=2):
228
+ """截图留存策略:中间(非验收)运行目录即用即弃,验收目录永久保护。
229
+
230
+ - 当前运行目录(SHOT_DIR)永不清
231
+ - 带 .acceptance 标记的验收目录:保留最近 acceptance_keep 次(全程截图证据)
232
+ - 其余中间运行目录:保留最近 keep 次(默认 0=即用即弃,agent 当轮取证后即回收)"""
233
+ import glob as _g
234
+ runs = sorted(_g.glob(os.path.join('reports', '*', '*')),
235
+ key=lambda p: os.path.getmtime(p) if os.path.isdir(p) else 0, reverse=True)
236
+ runs = [r for r in runs if os.path.isdir(r)]
237
+ pinned = [r for r in runs if os.path.exists(os.path.join(r, '.acceptance'))]
238
+ regular = [r for r in runs if r not in pinned]
239
+ doomed = [r for r in regular[keep:] if os.path.abspath(r) != os.path.abspath(SHOT_DIR or '')]
240
+ doomed += pinned[acceptance_keep:]
241
+ freed = 0
242
+ for p in doomed:
243
+ for dirpath, _dirs, files in os.walk(p):
244
+ for fn in files:
245
+ try:
246
+ freed += os.path.getsize(os.path.join(dirpath, fn))
247
+ except OSError:
248
+ pass
249
+ import shutil as _sh
250
+ _sh.rmtree(p, ignore_errors=True)
251
+ for dp in _g.glob(os.path.join('reports', '*')):
252
+ if os.path.isdir(dp) and not os.listdir(dp):
253
+ os.rmdir(dp)
254
+ if doomed:
255
+ print(f'[retention] 中间运行即用即弃: 清理 {len(doomed)} 个旧目录 -{freed // 1048576}MB'
256
+ f'(验收目录保留 {len(pinned[:acceptance_keep])} 个)')
257
+
258
+
259
+ # ---------------- 产品代码变更检测(增量重试回归门禁) ----------------
260
+ # 背景:--retry-failed 只重跑 FAIL/SKIP,PASS 保留原结果;若上一轮之后修改了产品代码
261
+ # (entry/src/main/ets),旧 PASS 可能已回归(实证:Profiles TC09/TC14 PASS→FAIL 靠全量重跑才抓到)。
262
+ # 门禁:结果侧车 results.meta.json 记录 srcHash;增量重试前重算,不一致 → 自动升级为全量重跑。
263
+
264
+ def _find_src_root():
265
+ """定位被测工程 entry/src/main/ets:FORGE_SRC_DIR 优先;否则在父/祖父目录的
266
+ *Ohos|ohos 兄弟工程中,按 forge 目录名令牌匹配(timer-hypium→TimerOhos);
267
+ 匹配失败且候选不唯一时返回 None(门禁将保守全量,宁可多跑不漏回归)。"""
268
+ env = os.environ.get('FORGE_SRC_DIR')
269
+ if env and os.path.isdir(env):
270
+ return env
271
+ import re as _re
272
+ here = os.path.abspath(os.getcwd())
273
+
274
+ def _norm(s):
275
+ s = s.lower()
276
+ changed = True
277
+ while changed:
278
+ changed = False
279
+ for suf in ('-ohos', 'ohos', '-hypium', 'hypium', '-forge', 'forge', '-migration', 'migration'):
280
+ if s.endswith(suf) and len(s) > len(suf):
281
+ s = s[:-len(suf)]
282
+ changed = True
283
+ return s
284
+
285
+ token = _norm(_re.split(r'[-_.]', os.path.basename(here))[0])
286
+ if token in ('hypium', 'tmp', 'testcases', 'forge', ''): # <X>-migration/hypium_forge → 用父目录令牌
287
+ token = _norm(_re.split(r'[-_.]', os.path.basename(os.path.dirname(here)))[0])
288
+ cands = [] # (src_root, 归一化工程名)
289
+ for base in (os.path.dirname(here), os.path.dirname(os.path.dirname(here))):
290
+ if not base or not os.path.isdir(base):
291
+ continue
292
+ for d in sorted(os.listdir(base)):
293
+ if d.endswith('Ohos') or d == 'ohos':
294
+ cand = os.path.join(base, d, 'entry', 'src', 'main', 'ets')
295
+ if os.path.isdir(cand):
296
+ cands.append((cand, _norm(d)))
297
+ if not cands:
298
+ return None
299
+ if len(cands) == 1:
300
+ return cands[0][0]
301
+ for cand, dn in cands:
302
+ if dn == token or dn.startswith(token) or token.startswith(dn):
303
+ return cand
304
+ return None
305
+
306
+
307
+ def src_tree_hash():
308
+ """entry/src/main/ets 下全部 .ets/.ts 内容哈希(排序后串联),产品代码指纹。"""
309
+ root = _find_src_root()
310
+ if not root:
311
+ return None
312
+ h = hashlib.sha256()
313
+ for dirpath, dirs, files in os.walk(root):
314
+ dirs[:] = sorted(d for d in dirs if d not in ('build', 'oh_modules', '.preview'))
315
+ for fn in sorted(files):
316
+ if not fn.endswith(('.ets', '.ts')):
317
+ continue
318
+ p = os.path.join(dirpath, fn)
319
+ h.update(os.path.relpath(p, root).encode())
320
+ try:
321
+ h.update(open(p, 'rb').read())
322
+ except OSError:
323
+ return None
324
+ return h.hexdigest()[:16]
325
+
326
+
327
+ def src_file_hashes():
328
+ """每个 .ets/.ts 文件内容哈希 {相对路径: hash}——范围回归的变更定位基础。"""
329
+ root = _find_src_root()
330
+ if not root:
331
+ return None
332
+ out = {}
333
+ for dirpath, dirs, files in os.walk(root):
334
+ dirs[:] = sorted(d for d in dirs if d not in ('build', 'oh_modules', '.preview'))
335
+ for fn in sorted(files):
336
+ if not fn.endswith(('.ets', '.ts')):
337
+ continue
338
+ p = os.path.join(dirpath, fn)
339
+ try:
340
+ out[os.path.relpath(p, root)] = hashlib.sha1(open(p, 'rb').read()).hexdigest()[:12]
341
+ except OSError:
342
+ return None
343
+ return out
344
+
345
+
346
+ def _meta_path(result_path):
347
+ return os.path.join(os.path.dirname(os.path.abspath(result_path)) or '.',
348
+ 'results.meta.json')
349
+
350
+
351
+ def _read_prev_meta(result_path):
352
+ mp = _meta_path(result_path)
353
+ if os.path.exists(mp):
354
+ try:
355
+ return json.load(open(mp, encoding='utf-8'))
356
+ except Exception:
357
+ return {}
358
+ return {}
359
+
360
+
361
+ def _read_prev_src_hash(result_path):
362
+ return _read_prev_meta(result_path).get('srcHash')
363
+
364
+
365
+ def _update_ladder_meta(result_path, last_full_hash=None, explicit_full=False, all_pass=False, mode=None):
366
+ """TD-LADDER v2 预算状态(与 srcHash 记录共存于 results.meta.json):
367
+ - last_full_hash: 最近一次"全量"运行的源码指纹(供"源码未变禁重复全量"判定);
368
+ - explicit_full: 本轮是否显式全量(--all/--acceptance,不含 --retry-failed 自动升级)→ 计次;
369
+ - all_pass: 本轮零失败 → 计次归零,开启新修复周期。"""
370
+ try:
371
+ mp = _meta_path(result_path)
372
+ meta = {}
373
+ if os.path.exists(mp):
374
+ try:
375
+ meta = json.load(open(mp, encoding='utf-8'))
376
+ except Exception:
377
+ meta = {}
378
+ if last_full_hash:
379
+ meta['lastFullSrcHash'] = last_full_hash
380
+ meta['lastFullTs'] = time.time()
381
+ if mode:
382
+ meta['lastMode'] = mode
383
+ if all_pass:
384
+ meta['explicitFullCount'] = 0
385
+ elif explicit_full:
386
+ meta['explicitFullCount'] = int(meta.get('explicitFullCount') or 0) + 1
387
+ json.dump(meta, open(mp, 'w', encoding='utf-8'), indent=1)
388
+ except Exception:
389
+ pass
390
+
391
+
392
+ def _write_src_meta(result_path, src_hash, mode, files=None):
393
+ if not src_hash:
394
+ return
395
+ try:
396
+ mp = _meta_path(result_path)
397
+ meta = {'srcHash': src_hash, 'ts': time.time(), 'mode': mode}
398
+ if files:
399
+ meta['files'] = files # 供下轮范围回归 diff(旧 meta 无此键 → 保守全量)
400
+ json.dump(meta, open(mp, 'w', encoding='utf-8'), indent=1)
401
+ except Exception:
402
+ pass
403
+
404
+
405
+ # ---------------- 范围回归(touched scope):改动定位 → 受影响用例子集 ----------------
406
+ # 门禁升级不再非黑即白:产品代码变更时,若改动文件全部是"用例可定位的 UI 文件"
407
+ # (ids.json 键前缀),则只重跑 FAIL/SKIP + 引用了这些文件的用例;其余 PASS 保留。
408
+ # 任一改动文件是共享逻辑(viewmodel/repository/model)或不可定位 → 保守全量。
409
+ # 引用识别不到依赖(纯文本交互)的 PASS 用例保守并入范围;范围超 60% 直接全量。
410
+
411
+ _STR_LIT = re.compile(r"""['"]([A-Za-z_][A-Za-z0-9_]{2,})['"]""")
412
+
413
+
414
+ def _load_id_file_map(ids_path):
415
+ """ids.json(preinject 产物){'File:locator': id} → {id: 文件基名(lower)}。"""
416
+ try:
417
+ m = json.load(open(ids_path, encoding='utf-8'))
418
+ return {str(v): str(k).split(':')[0].lower() for k, v in m.items()}
419
+ except Exception:
420
+ return None
421
+
422
+
423
+ def case_dep_files(path, id2file):
424
+ """用例源码中引用的 id 字面量 → 依赖 UI 文件基名集合(空集=纯文本交互,无法定位)。"""
425
+ try:
426
+ src = open(path, encoding='utf-8').read()
427
+ except Exception:
428
+ return None
429
+ deps = set()
430
+ for lit in _STR_LIT.findall(src):
431
+ f = id2file.get(lit)
432
+ if f:
433
+ deps.add(f)
434
+ return deps
435
+
436
+
437
+ def plan_touched_scope(changed_rel, id2file, dir_):
438
+ """返回受影响 UI 文件基名集合;改动越界(非 UI 文件)返回 None=保守全量。"""
439
+ changed_bases = {os.path.basename(c).rsplit('.', 1)[0].lower() for c in changed_rel}
440
+ ui_bases = set(id2file.values())
441
+ if not changed_bases <= ui_bases:
442
+ return None
443
+ return changed_bases
444
+
445
+
446
+
447
+ HDC_BIN = '/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc'
448
+ DEVICE_SN = None # main() 里从 args.device 赋值
449
+
450
+
451
+ def _shot(d, path, label='shot'):
452
+ """截图(尽力而为,绝不影响结果记录):d.screenshot 优先,失败走 hdc snapshot_display。"""
453
+ os.makedirs(os.path.dirname(path) if os.path.dirname(path) else '.', exist_ok=True)
454
+ try:
455
+ d.screenshot(path)
456
+ print(f' [{label}] {path}')
457
+ return
458
+ except Exception:
459
+ pass
460
+ try:
461
+ import subprocess as _sp
462
+ dev = DEVICE_SN or os.environ.get('FORGE_DEVICE', '127.0.0.1:5555')
463
+ remote = f'/data/local/tmp/_forge_shot.jpeg'
464
+ _sp.run([HDC_BIN, '-t', dev, 'shell', 'snapshot_display', '-f', remote],
465
+ capture_output=True, timeout=10)
466
+ _sp.run([HDC_BIN, '-t', dev, 'file', 'recv', remote, path],
467
+ capture_output=True, timeout=15)
468
+ _sp.run([HDC_BIN, '-t', dev, 'shell', 'rm', '-f', remote],
469
+ capture_output=True, timeout=5)
470
+ print(f' [{label}] {path} (hdc)')
471
+ except Exception as exc:
472
+ print(f' [{label}] 不可用: {exc}')
473
+
474
+
475
+ def main():
476
+ ap = argparse.ArgumentParser()
477
+ ap.add_argument('--case', default=None)
478
+ ap.add_argument('--all', action='store_true')
479
+ ap.add_argument('--dir', default='testcases')
480
+ ap.add_argument('--device', default='127.0.0.1:5555')
481
+ ap.add_argument('--retry-failed', action='store_true',
482
+ help='增量重试:只重跑 results.json 中 FAIL/SKIP 条(PASS 保留原结果,合并写出);'
483
+ '产品代码(entry/src/main/ets)较上轮有变更时自动升级为全量重跑')
484
+ ap.add_argument('--force-incremental', action='store_true',
485
+ help='跳过产品代码变更门禁,强制增量(仅测试脚本变更时使用)')
486
+ ap.add_argument('--regression-scope', choices=('touched', 'full'), default=None,
487
+ help='产品代码变更时的回归范围: touched=FAIL/SKIP+引用改动UI文件的用例(默认) | full=全量'
488
+ '(env FORGE_REGRESSION_SCOPE 同义;改动含共享逻辑文件时 touched 自动退化为 full)')
489
+ ap.add_argument('--cold', action='store_true',
490
+ help='--case 单条验证时强制冷启(默认热启动软复用,省 8-12s;断言冷启行为时用)')
491
+ ap.add_argument('--case-timeout', type=int, default=240,
492
+ help='单用例看门狗秒数(默认 240,超时记 FAIL 继续下一条,防挂死烧设备时间;0=关闭)')
493
+ ap.add_argument('--flake-retry', type=int, default=1,
494
+ help='瞬态失败(watchdog/连接类)轮末自动复测次数(默认 1,省一轮 agent 往返;0=关闭)')
495
+ ap.add_argument('--filter', default=None,
496
+ help='子串过滤用例文件名(保留分组序),如 --filter TC3 跑 TC30/TC31/TC35')
497
+ ap.add_argument('--shots', choices=('fail', 'all', 'off'), default=None,
498
+ help='截图策略: fail=PASS只留end帧/FAIL留全过程(默认) | all=全程留存 | off=不截')
499
+ ap.add_argument('--keep-reports', type=int, default=None,
500
+ help='中间(非验收)运行目录保留最近 N 份(默认 0=即用即弃,当轮取证后即回收;'
501
+ 'env FORGE_KEEP_REPORTS)。验收目录(.acceptance)不受此限制,独立保留最近 2 次')
502
+ ap.add_argument('--force-all', action='store_true',
503
+ help='跳过修复轮 --all 防护(仅限确有必要的全量场景;正常流程: --case 单条 → '
504
+ '--retry-failed 增量 → --acceptance 终局验收)')
505
+ ap.add_argument('--acceptance', action='store_true',
506
+ help='验收模式:强制全量 + 全程截图(--shots all,显式传 --shots 时以显式为准),'
507
+ '结果目录写入 .acceptance 标记——prune 永不清理最近的验收目录(保留最近 2 次),'
508
+ '作为最终质量门的完整截图证据')
509
+ args = ap.parse_args()
510
+ # 阶梯防护 v2(TD-LADDER):所有"显式全量"(--all / --acceptance / 变相全量的 --filter)统一受管。
511
+ # R1 上轮有 FAIL/SKIP → 必须先修(--case/--retry-failed),禁止全量;
512
+ # R2/R2b 源码未变且已全量过 → 重复全量无新结论;--acceptance 仅当"上次也是同源验收"才拦;
513
+ # R3 本周期显式全量已达上限 → 见 LADDER_FULL_BUDGET;
514
+ # L2 零进展(连续两轮失败签名一致且源码未变)→ 拒绝重跑,必须先 probe 定案改码。
515
+ # 免检:--retry-failed 因源码变更自动升级的全量(非显式,不占预算);--force-all 逃生。
516
+ _prev_path = os.path.join(os.path.abspath(args.dir), '..', 'results.json')
517
+ _prev_res, _meta = None, {}
518
+ if os.path.exists(_prev_path):
519
+ try:
520
+ _prev_res = json.load(open(_prev_path, encoding='utf-8'))
521
+ except Exception:
522
+ _prev_res = None
523
+ _meta = _read_prev_meta(_prev_path)
524
+ _pending = [str(r.get('case', '?')) for r in (_prev_res or [])
525
+ if r.get('status') in ('FAIL', 'SKIP')]
526
+ _cur_hash = src_tree_hash()
527
+ _last_full_hash = _meta.get('lastFullSrcHash')
528
+ _full_count = int(_meta.get('explicitFullCount') or 0)
529
+
530
+ # L1:--filter 覆盖超半数用例 = 变相全量,按显式全量纳管
531
+ _filter_full = False
532
+ if args.filter:
533
+ try:
534
+ _all_files = order_files(args.dir)
535
+ _hit = [f for f, _g in _all_files if args.filter in f]
536
+ _filter_full = bool(_all_files) and len(_hit) > max(5, int(len(_all_files) * 0.25))
537
+ if _filter_full:
538
+ print(f'ℹ --filter "{args.filter}" 覆盖 {len(_hit)}/{len(_all_files)} 条(>25% 且 >5)= 变相全量,按全量纳管')
539
+ except Exception:
540
+ _filter_full = False
541
+
542
+ def _ladder_block(reason):
543
+ print('⛔ 阶梯防护:拒绝显式全量(--all / --acceptance / 变相 --filter)')
544
+ print(f' 原因:{reason}')
545
+ print(' ▶ 单条验证: --case <失败用例>')
546
+ print(' ▶ 增量重跑: --retry-failed(源码变更时按 srcHash 自动收窄/升级)')
547
+ print(' ▶ 全绿后再做 终局验收: --acceptance(证据留档,全程截图)')
548
+ print(' ▶ 确有必要的一次性全量: --force-all(跳过防护,自担"无进展"风险)')
549
+ sys.exit(2)
550
+
551
+ explicit_full = (args.all or args.acceptance or _filter_full) and not args.force_all
552
+ if explicit_full:
553
+ _reason = None
554
+ if _pending:
555
+ _reason = (f'上轮 results.json 尚有 {len(_pending)} 条 FAIL/SKIP:'
556
+ + '、'.join(_pending[:6]) + (' …' if len(_pending) > 6 else ''))
557
+ elif (not args.acceptance) and _cur_hash and _last_full_hash == _cur_hash:
558
+ _reason = '源码自上次全量以来未变化(srcHash 相同),重复全量不会得到新结论'
559
+ elif (args.acceptance and _meta.get('lastMode') == 'acceptance'
560
+ and _cur_hash and _last_full_hash == _cur_hash):
561
+ _reason = '上次已是同源验收(srcHash 相同),无需重复验收'
562
+ elif _full_count >= LADDER_FULL_BUDGET:
563
+ _reason = f'本修复周期显式全量已达 {_full_count} 次(上限 {LADDER_FULL_BUDGET})'
564
+ if _reason:
565
+ _ladder_block(_reason)
566
+
567
+ # L2:零进展硬阻断——连续两轮失败签名一致且源码未变时,重跑(增量/全量)必然同样失败。
568
+ if not args.force_all and not args.case and _cur_hash and _pending \
569
+ and _meta.get('srcHash') == _cur_hash:
570
+ _prev_prev = os.path.join(os.path.abspath(args.dir), '..', 'results.prev.json')
571
+ if os.path.exists(_prev_prev):
572
+ try:
573
+ _pp = json.load(open(_prev_prev, encoding='utf-8'))
574
+
575
+ def _failsig(lst):
576
+ return sorted({(r.get('msg') or '').split(' | ')[0][:60]
577
+ for r in lst if r.get('status') == 'FAIL'})
578
+
579
+ if _failsig(_pp) and _failsig(_pp) == _failsig(_prev_res or []):
580
+ print('⛔ 阶梯防护(L2 零进展):连续两轮失败签名集完全一致,且源码未变化。')
581
+ print(' 重复重跑必然同样失败——请先用 probe.py(tree/hilog/db-pull)定案并修改代码。')
582
+ print(' 确需强跑: --force-all')
583
+ sys.exit(2)
584
+ except Exception:
585
+ pass
586
+ global DEVICE_SN, SHOT_MODE
587
+ DEVICE_SN = args.device
588
+ SHOT_MODE = args.shots or os.environ.get('FORGE_SHOT_MODE', 'fail')
589
+ if args.acceptance:
590
+ args.all = True # 验收=强制全量
591
+ if not args.shots:
592
+ SHOT_MODE = 'all' # 验收全程截图(显式 --shots 优先)
593
+ print('═══ 验收模式: 全量 + 全程截图 + .acceptance 标记(prune 保护) ═══')
594
+ if SHOT_MODE not in ('fail', 'all', 'off'):
595
+ SHOT_MODE = 'fail'
596
+ os.environ['FORGE_SHOT_MODE'] = SHOT_MODE # step_shot(_common) 同步感知 off
597
+ keep_reports = args.keep_reports if args.keep_reports is not None else int(
598
+ os.environ.get('FORGE_KEEP_REPORTS', '0') or 0)
599
+ sys.path.insert(0, os.path.abspath(args.dir))
600
+
601
+ if args.case:
602
+ ordered = [(f'{args.case}.py', 'anonymous')]
603
+ elif args.retry_failed:
604
+ prev = os.path.join(os.path.abspath(args.dir), '..', 'results.json')
605
+ if not os.path.exists(prev):
606
+ print('--retry-failed 需要 results.json'); sys.exit(1)
607
+ # ---- 回归门禁:产品代码变更 → 全量 或 范围回归(touched,TD-019+)----
608
+ cur_hash = src_tree_hash()
609
+ if not args.force_incremental:
610
+ prev_hash = _read_prev_src_hash(prev)
611
+ if cur_hash is None:
612
+ print('⚠ 未定位到 entry/src/main/ets(可用 FORGE_SRC_DIR 指定),无法判定产品代码变更 → 保守全量重跑')
613
+ args.all = True
614
+ elif prev_hash is None:
615
+ print('⚠ results.meta.json 无上轮 srcHash 记录(旧版产物)→ 保守全量重跑(本轮后建立指纹)')
616
+ args.all = True
617
+ elif cur_hash != prev_hash:
618
+ scope_mode = (args.regression_scope
619
+ or os.environ.get('FORGE_REGRESSION_SCOPE') or 'touched')
620
+ touched = None
621
+ if scope_mode == 'touched':
622
+ prev_files = _read_prev_meta(prev).get('files')
623
+ cur_files = src_file_hashes()
624
+ if prev_files and cur_files:
625
+ changed_rel = {r for r in cur_files if prev_files.get(r) != cur_files[r]}
626
+ changed_rel |= set(prev_files) - set(cur_files) # 删除/改名也算
627
+ ids_path = os.path.join(os.path.dirname(os.path.abspath(prev)), 'ids.json')
628
+ id2file = _load_id_file_map(ids_path)
629
+ if id2file is not None and changed_rel:
630
+ touched = plan_touched_scope(changed_rel, id2file, args.dir)
631
+ globals()['_ID2FILE'] = id2file
632
+ if touched:
633
+ print(f'⚡ 产品代码变更限于 UI 文件 {sorted(touched)} → 范围回归: '
634
+ f'FAIL/SKIP + 引用这些文件的用例(其余 PASS 保留)')
635
+ globals()['_SCOPE_BASES'] = touched
636
+ else:
637
+ print(f'⚠ 产品代码已变更(srcHash {prev_hash} → {cur_hash}):改动含共享/不可定位文件 '
638
+ f'→ 自动升级为全量重跑(改单页时可检查 ids.json 覆盖;强制收窄 --regression-scope touched)')
639
+ args.all = True
640
+ prev_res = {str(r.get('case')).split('_')[0]: r
641
+ for r in json.load(open(prev, encoding='utf-8'))}
642
+ all_ordered = order_files(args.dir)
643
+ if args.all: # 门禁升级为全量:不保留旧 PASS
644
+ ordered = all_ordered
645
+ print(f'全量重跑(产品代码变更门禁): {len(ordered)} 条')
646
+ else:
647
+ ordered = [(f, g) for f, g in all_ordered
648
+ if f[:-3].split('_')[0] not in prev_res
649
+ or prev_res[f[:-3].split('_')[0]].get('status') != 'PASS']
650
+ scope_bases = globals().get('_SCOPE_BASES')
651
+ if scope_bases: # touched:并入"引用改动文件的 PASS 用例"(无依赖的保守并入)
652
+ id2file = globals().get('_ID2FILE') or {}
653
+ extra, ambiguous = [], 0
654
+ for f, g in all_ordered:
655
+ cid = f[:-3].split('_')[0]
656
+ if cid in prev_res and prev_res[cid].get('status') == 'PASS':
657
+ deps = case_dep_files(os.path.join(args.dir, f), id2file)
658
+ if deps is None or not deps or (deps & scope_bases):
659
+ if deps is None or not deps:
660
+ ambiguous += 1
661
+ extra.append((f, g))
662
+ if len(ordered) + len(extra) > max(3, int(len(all_ordered) * 0.6)):
663
+ print(f'⚠ 范围回归覆盖 {len(ordered) + len(extra)}/{len(all_ordered)} 条(含无依赖 {ambiguous})'
664
+ f'→ 超过 60% 阈值,升级全量')
665
+ ordered = all_ordered
666
+ args.all = True
667
+ else:
668
+ ordered = ordered + extra
669
+ ordered.sort(key=lambda x: (GROUP_ORDER[x[1]], x[0]))
670
+ print(f'范围回归: FAIL/SKIP+受影响 {len(ordered)} 条(含无依赖纯文本用例 {ambiguous},保守并入)')
671
+ keep = [prev_res[f[:-3].split('_')[0]] for f, g in all_ordered
672
+ if f[:-3].split('_')[0] in prev_res
673
+ and prev_res[f[:-3].split('_')[0]].get('status') == 'PASS'
674
+ and (f, g) not in ordered]
675
+ print(f'增量重试: 重跑 {len(ordered)} 条 / 保留 PASS {len(keep)} 条')
676
+ globals()['_RETRY_KEEP'] = keep
677
+ elif args.all or args.filter:
678
+ ordered = order_files(args.dir)
679
+ if args.filter:
680
+ ordered = [(f, g) for f, g in ordered if args.filter in f]
681
+ if not ordered:
682
+ print(f'--filter "{args.filter}" 无匹配用例'); sys.exit(1)
683
+ else:
684
+ print('指定 --case X / --all / --retry-failed / --filter'); sys.exit(1)
685
+
686
+ _init_shot_dir()
687
+ os.environ['FORGE_DEVICE'] = args.device # launch(want/reset) 的 hdc 透传用
688
+
689
+ d = UiDriver.connect(device_sn=args.device)
690
+ import signal
691
+
692
+ def _watchdog_arm(sec):
693
+ if sec and hasattr(signal, 'SIGALRM'):
694
+ def _handler(signum, frame):
695
+ raise TimeoutError(f'case watchdog {sec}s')
696
+ signal.signal(signal.SIGALRM, _handler)
697
+ signal.alarm(sec)
698
+
699
+ def _watchdog_disarm():
700
+ if hasattr(signal, 'SIGALRM'):
701
+ signal.alarm(0)
702
+
703
+ results = []
704
+ cur_group = None
705
+ first_in_group = True
706
+ for f, grp in ordered:
707
+ name = f[:-3]
708
+ if grp != cur_group: # 组切换:首启组排尾,此前组状态不被回头破坏
709
+ cur_group = grp
710
+ first_in_group = True
711
+ print(f'\n────── 前置组 [{grp}] 开始({grp} 组内软隔离共享前置)──────', flush=True)
712
+ os.environ['FORGE_GROUP_FIRST'] = '1'
713
+ # 热启动:组内非首条且锚已在位时 launch() 软复用当前会话(省冷启 8-12s/例);
714
+ # first_launch 组(bm clean)与组首条(冷启验证)保持冷启;
715
+ # --case 单条验证默认热启动(--cold 强制冷启)
716
+ warm = (grp in ('anonymous', 'logged_in') and not first_in_group) or \
717
+ (args.case and not args.cold and grp != 'first_launch')
718
+ if warm:
719
+ os.environ['FORGE_WARM_LAUNCH'] = '1'
720
+ else:
721
+ os.environ.pop('FORGE_WARM_LAUNCH', None)
722
+ print(f'▶ {name} ...', flush=True)
723
+ _watchdog_arm(args.case_timeout)
724
+ try:
725
+ n, status, msg, dur = run_one(d, os.path.join(args.dir, f), name)
726
+ except TimeoutError as te:
727
+ n, status, msg, dur = name, 'FAIL', f'{te}(挂死截断,已跳下一条)', round(time.time(), 0)
728
+ finally:
729
+ _watchdog_disarm()
730
+ os.environ.pop('FORGE_GROUP_FIRST', None)
731
+ first_in_group = False
732
+ print(f' {status} ({dur}s) {msg[:110]}', flush=True)
733
+ if status == 'PASS' and SHOT_MODE == 'fail':
734
+ _clean_pass_shots(name) # PASS 只留 end 帧,FAIL 保留全过程证据
735
+ results.append({'case': n, 'status': status, 'msg': msg, 'dur': dur, 'group': grp})
736
+ time.sleep(0.5) # launch 自带落座等待,长睡交给用例前置
737
+
738
+ # ---- 瞬态失败轮末复测:watchdog/连接类抖动当场再跑一次,PASS 记 flake-retry ----
739
+ # (省一轮"agent 读结果→重跑全量"往返;断言类失败不自动复测,防掩盖真缺陷)
740
+ if args.flake_retry > 0:
741
+ TRANSIENT = re.compile(r'watchdog|connector|Fail to get device|device disconnect|TimeoutError', re.I)
742
+ for r in list(results):
743
+ if r['status'] == 'FAIL' and TRANSIENT.search(r.get('msg', '')):
744
+ print(f'▶ {r["case"]} 瞬态失败特征,轮末复测...', flush=True)
745
+ os.environ.pop('FORGE_WARM_LAUNCH', None) # 复测走完整冷启(连接类抖动需重连)
746
+ _watchdog_arm(args.case_timeout)
747
+ try:
748
+ n2, st2, msg2, dur2 = run_one(d, os.path.join(args.dir, r['case'] + '.py'), r['case'])
749
+ except TimeoutError as te:
750
+ n2, st2, msg2, dur2 = r['case'], 'FAIL', str(te), 0
751
+ finally:
752
+ _watchdog_disarm()
753
+ if st2 == 'PASS':
754
+ r.update({'status': 'PASS',
755
+ 'msg': f'flake-retry 二跑 PASS(首跑: {r["msg"][:60]})',
756
+ 'dur': round(r['dur'] + dur2, 1)})
757
+ print(f' PASS(2nd) {r["case"]}', flush=True)
758
+ else:
759
+ r['msg'] = f'{r["msg"]} | 复测仍失败: {msg2[:60]}'
760
+
761
+ results = globals().get('_RETRY_KEEP', []) + results
762
+ p = sum(1 for r in results if r['status'] == 'PASS')
763
+ f_ = sum(1 for r in results if r['status'] == 'FAIL')
764
+ print(f'\n══════ 直跑汇总: PASS={p} FAIL={f_} SKIP={len(results)-p-f_} / {len(results)} ══════')
765
+ from collections import Counter
766
+ print('分组: ' + ', '.join(f'{g}={c}' for g, c in Counter(r['group'] for r in results).items()))
767
+ # 同族错误聚类(分诊三律①:同消息视为同族,一次定案禁逐条实验)
768
+ fails = [r for r in results if r['status'] == 'FAIL']
769
+ if fails:
770
+ import re as _re
771
+ def _sig(m):
772
+ head = (m or '').split(' | ')[0]
773
+ return _re.sub(r'[::].+', ': X', head)[:60]
774
+ clus = Counter(_sig(r.get('msg')) for r in fails)
775
+ print('同族聚类: ' + ' ; '.join(f'{c}×[{k}]' for k, c in clus.most_common(4)))
776
+ out = 'results.json' if (args.all or args.retry_failed) else 'results-%s.json' % (args.case or 'single').split('_')[0]
777
+ # ---- 零进展检测:失败签名集与上一轮完全一致 → 修复未生效,重跑必复现 ----
778
+ # (Grit 实证泥潭:taskSaveButton 同族失败连卡 5 轮全量 71min。触发即亮牌,
779
+ # agent 应停止重跑、转 probe.py/hilog 定案——分诊三律② 同族定案实验 ≤2 次)
780
+ if fails and os.path.exists(out):
781
+ try:
782
+ prev_list = json.load(open(out, encoding='utf-8'))
783
+
784
+ def _sigset(lst):
785
+ from collections import Counter as _C
786
+ return _C(re.sub(r'[::].+', '', (r.get('msg') or '')).split(' | ')[0][:60]
787
+ for r in lst if r.get('status') == 'FAIL')
788
+
789
+ if _sigset(prev_list) and _sigset(prev_list) == _sigset(results):
790
+ print('\n⚠⚠ 零进展警告: 失败签名集与上一轮完全一致('
791
+ + ', '.join(f'{c}×{k}' for k, c in _sigset(results).most_common(3))
792
+ + ')——修复未生效,重复重跑必然同样失败。'
793
+ '停止重跑,用 probe.py(tree/hilog/db-pull)定案后改代码,再回来跑。')
794
+ except Exception:
795
+ pass
796
+ if os.path.exists(out): # 修复轮重跑前保留上一轮原始记录(自愈边界:修≠覆盖历史)
797
+ import shutil as _sh
798
+ _sh.copy(out, out.replace('.json', '.prev.json'))
799
+ json.dump(results, open(out, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
800
+ if args.all or args.retry_failed: # 全量/合并结果写入后记录产品代码指纹(供下轮增量门禁/范围回归比对)
801
+ _cur_src_hash = src_tree_hash()
802
+ _mode = 'acceptance' if args.acceptance else ('full' if args.all else 'retry')
803
+ _write_src_meta(out, _cur_src_hash, _mode, files=src_file_hashes())
804
+ _update_ladder_meta(out, last_full_hash=(_cur_src_hash if args.all else None),
805
+ explicit_full=explicit_full, all_pass=(f_ == 0), mode=_mode)
806
+ if args.acceptance and SHOT_DIR: # 验收目录打标:prune 独立保护,全程截图永久留存
807
+ try:
808
+ json.dump({'ts': time.time(), 'total': len(results), 'pass': p, 'fail': f_,
809
+ 'srcHash': _read_prev_src_hash(out) or ''},
810
+ open(os.path.join(SHOT_DIR, '.acceptance'), 'w', encoding='utf-8'), indent=1)
811
+ print(f'[acceptance] 验收证据目录已保护: {SHOT_DIR}(PASS={p} FAIL={f_})')
812
+ except Exception:
813
+ pass
814
+ print('明细: ' + out)
815
+ if fails and not args.retry_failed: # 阶梯重跑提示(防修复后无脑 --all 空转设备时间)
816
+ first_fail = fails[0]['case']
817
+ print(f'\n▶ 下一步(重跑阶梯,禁直接 --all): '
818
+ f'先 `--case {first_fail}` 验证修复 → 再 `--retry-failed`(改过产品代码会自动升级全量)')
819
+ _prune_reports(keep_reports)
820
+
821
+
822
+ if __name__ == '__main__':
823
+ main()