@harmonyos-arkts/d2h 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +15 -13
- package/.claude-plugin/plugin.json +13 -7
- package/AGENTS.md +5 -5
- package/README.md +212 -212
- package/agents/android-to-hmos-00-orchestrator.md +318 -318
- package/agents/d2h.md +157 -157
- package/dist/bin/install-opencode.js +30 -30
- package/docs/environment-check.md +86 -86
- package/install-opencode.sh +160 -160
- package/opencode/agents.json +16 -16
- package/opencode/vscode-d2h-package.json +7 -7
- package/opencode/vscode-d2h-plugin.mjs +29 -29
- package/package.json +54 -54
- package/schemas/android-source-manifest.schema.json +25 -25
- package/schemas/checkpoint-provenance.schema.json +67 -67
- package/schemas/final-acceptance.schema.json +29 -29
- package/schemas/managed-evidence-index.schema.json +15 -15
- package/schemas/managed-evidence.schema.json +114 -114
- package/schemas/migration-config.schema.json +51 -51
- package/schemas/migration-report-index.schema.json +39 -39
- package/schemas/migration-report-item.schema.json +51 -51
- package/schemas/migration-report-summary.schema.json +25 -25
- package/schemas/migration-status.schema.json +202 -202
- package/schemas/preflight.schema.json +60 -60
- package/schemas/source-order-audit.schema.json +16 -16
- package/schemas/source-provenance-event.schema.json +19 -19
- package/schemas/spec-app-shard.schema.json +45 -45
- package/schemas/spec-fact-corrections-shard.schema.json +13 -13
- package/schemas/spec-features-shard.schema.json +43 -43
- package/schemas/spec-index.schema.json +26 -26
- package/schemas/spec-interactions-shard.schema.json +40 -40
- package/schemas/spec-page.schema.json +98 -98
- package/schemas/spec-pages-index.schema.json +1 -1
- package/schemas/spec-unresolved-shard.schema.json +1 -1
- package/schemas/task-envelope.schema.json +109 -109
- package/schemas/task-plan.schema.json +341 -341
- package/scripts/check-environment.mjs +320 -320
- package/skills/android2hmos_resources_convert/SKILL.md +180 -180
- package/skills/android2hmos_resources_convert/references/image-conversion-rules.md +230 -230
- package/skills/android2hmos_resources_convert/references/svg-fix-patterns.md +175 -175
- package/skills/android2hmos_resources_convert/references/xml-drawable-to-svg-rules.md +513 -513
- package/skills/arkts-app-identity/SKILL.md +238 -238
- package/skills/arkts-i18n/SKILL.md +496 -496
- package/skills/arkts-i18n/evals/evals.json +84 -84
- package/skills/arkts-i18n/references/code-examples.md +302 -302
- package/skills/arkts-i18n/references/common-pitfalls.md +391 -391
- package/skills/arkts-i18n/references/dynamic-language-switch.md +604 -604
- package/skills/arkts-i18n/references/hardcoded-string-scanner.md +348 -348
- package/skills/arkts-i18n/references/language-codes.md +104 -104
- package/skills/arkts-i18n/references/resource-file-structure.md +775 -775
- package/skills/arkts-i18n/references/static-vs-dynamic.md +242 -242
- package/skills/arkts-i18n/references/v1-compat.md +244 -244
- package/skills/arkts-i18n/scripts/audit_i18n_completeness.sh +174 -174
- package/skills/arkts-icon-sizing/SKILL.md +211 -211
- package/skills/arkts-icon-sizing/scripts/icon_audit.py +131 -131
- package/skills/arkts-icon-sizing/scripts/icon_autofix.py +88 -88
- package/skills/arkts-icon-sizing/scripts/icon_dims.py +179 -179
- package/skills/arkts-icon-sizing/scripts/icon_fix.py +119 -119
- package/skills/arkts-mvvm-architecture/SKILL.md +613 -613
- package/skills/d2h-hypium-forge/SKILL.md +309 -309
- package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -140
- package/skills/d2h-hypium-forge/references/official-api.md +135 -135
- package/skills/d2h-hypium-forge/references/pitfalls.md +98 -98
- package/skills/d2h-hypium-forge/references/testability-template.md +71 -71
- package/skills/d2h-hypium-forge/scripts/_common_template.py +659 -659
- package/skills/d2h-hypium-forge/scripts/analyze_results.py +165 -165
- package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -107
- package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -169
- package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -325
- package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -114
- package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -80
- package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -60
- package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -249
- package/skills/d2h-hypium-forge/scripts/precondition.py +140 -140
- package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -232
- package/skills/d2h-hypium-forge/scripts/probe.py +197 -197
- package/skills/d2h-hypium-forge/scripts/run_direct.py +823 -823
- package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -121
- package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -255
- package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -66
- package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -84
- package/skills/d2h-page-develop/SKILL.md +112 -112
- package/skills/d2h-page-develop/agents/openai.yaml +7 -7
- package/skills/d2h-page-spec-generate/SKILL.md +206 -206
- package/skills/d2h-page-spec-generate/scripts/build_batch_report.py +227 -227
- package/skills/d2h-utg-ai-generate/SKILL.md +128 -128
- package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -76
- package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -34
- package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -96
- package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -61
- package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -89
- package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -314
- package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -123
- package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -280
- package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -524
- package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -489
- package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -69
- package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -13
- package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -2
- package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -369
- package/skills/d2h-utg-static-generate/SKILL.md +73 -73
- package/skills/d2h-utg-static-generate/references/discoveries.md +38 -38
- package/skills/d2h-utg-static-generate/scripts/generate.py +499 -499
- package/skills/d2h-utg-static-generate/tests/test_static.py +335 -335
- package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -60
- package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -131
- package/skills/harmonyos-migration-playbook/SKILL.md +83 -83
- package/skills/harmonyos-migration-playbook/agents/openai.yaml +7 -7
- package/skills/harmonyos-migration-playbook/references/arkts-compile.md +24 -24
- package/skills/harmonyos-migration-playbook/references/harmony-runtime.md +53 -53
- package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +45 -45
- package/skills/harmonyos-migration-playbook/references/protocol-e2e.md +23 -23
- package/skills/harmonyos-migration-playbook/references/ui-automation.md +53 -53
- package/skills/harmonyos-migration-playbook/references/windows-environment.md +38 -38
- package/skills/maintaining-migration-report/SKILL.md +144 -144
- package/skills/maintaining-migration-report/references/final-acceptance.md +48 -48
- package/skills/maintaining-migration-report/references/source-provenance.md +66 -66
- package/skills/managing-d2h-toolchain/SKILL.md +24 -24
- package/skills/managing-d2h-toolchain/environment-requirements.json +16 -16
- package/skills/managing-d2h-toolchain/scripts/manage-toolchain.mjs +98 -98
- package/skills/managing-d2h-toolchain/toolchain.json +16 -16
- package/skills/native-library-substitution/SKILL.md +384 -384
- package/skills/native-library-substitution/references/native-library-substitution.json +56905 -56905
- package/skills/native-library-substitution/references/native-library-substitution.md +163 -163
- package/skills/preparing-migration-workspace/SKILL.md +131 -131
- package/skills/reviewing-migration-process/SKILL.md +62 -62
|
@@ -1,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()
|