@harmonyos-arkts/d2h 0.1.3 → 0.1.5
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/README.md +78 -44
- package/agents/android-to-hmos-00-orchestrator.md +34 -28
- package/agents/d2h.md +266 -95
- package/dist/bin/install-opencode.js +137 -43
- package/dist/src/install-opencode.js +44 -0
- package/dist/src/install-vscode.js +226 -0
- package/dist/src/toolchain.js +83 -0
- package/docs/environment-check.md +86 -0
- package/opencode/agents.json +1 -1
- package/opencode/vscode-d2h-package.json +7 -0
- package/opencode/vscode-d2h-plugin.mjs +29 -0
- package/package.json +6 -4
- package/schemas/task-envelope.schema.json +58 -4
- package/schemas/task-plan.schema.json +281 -63
- package/scripts/check-environment.mjs +321 -0
- package/skills/d2h-hypium-forge/SKILL.md +252 -0
- package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -0
- package/skills/d2h-hypium-forge/references/official-api.md +135 -0
- package/skills/d2h-hypium-forge/references/pitfalls.md +98 -0
- package/skills/d2h-hypium-forge/references/testability-template.md +71 -0
- package/skills/d2h-hypium-forge/scripts/_common_template.py +614 -0
- package/skills/d2h-hypium-forge/scripts/analyze_results.py +156 -0
- package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -0
- package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -0
- package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -0
- package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -0
- package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -0
- package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -0
- package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -0
- package/skills/d2h-hypium-forge/scripts/precondition.py +140 -0
- package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -0
- package/skills/d2h-hypium-forge/scripts/run_direct.py +392 -0
- package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -0
- package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -0
- package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -0
- package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -0
- package/skills/d2h-utg-ai-generate/SKILL.md +128 -0
- package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -0
- package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -0
- package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -0
- package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -0
- package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -0
- package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -0
- package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -0
- package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -0
- package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -0
- package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -0
- package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -0
- package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -0
- package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -0
- package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -0
- package/skills/d2h-utg-static-generate/SKILL.md +73 -0
- package/skills/d2h-utg-static-generate/references/discoveries.md +38 -0
- package/skills/d2h-utg-static-generate/scripts/generate.py +499 -0
- package/skills/d2h-utg-static-generate/tests/test_static.py +335 -0
- package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -0
- package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -0
- package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
- package/skills/harmonyos-migration-playbook/references/windows-environment.md +1 -1
- package/skills/managing-d2h-toolchain/SKILL.md +23 -0
- package/skills/managing-d2h-toolchain/environment-requirements.json +16 -0
- package/skills/managing-d2h-toolchain/toolchain.json +16 -0
- package/skills/preparing-migration-workspace/SKILL.md +9 -7
- package/agents/d2h_v2.md +0 -351
- package/skills/preparing-migration-workspace/toolchain.json +0 -19
|
@@ -0,0 +1,392 @@
|
|
|
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
|
+
|
|
18
|
+
要求: 用例 .py 文件含 process_case(d) 顶层函数;或兼容 devicetest 风格(反射 process 方法)。
|
|
19
|
+
结果: 逐条打印 PASS/FAIL + 汇总,写 results.json
|
|
20
|
+
"""
|
|
21
|
+
import argparse
|
|
22
|
+
import hashlib
|
|
23
|
+
import importlib.util
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import traceback
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
|
|
32
|
+
from hypium import UiDriver
|
|
33
|
+
|
|
34
|
+
# 前置组执行序:破坏性递增(first_launch 会 bm clean,必须排尾)
|
|
35
|
+
GROUP_ORDER = {'anonymous': 0, 'logged_in': 1, 'first_launch': 2}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_case(path):
|
|
39
|
+
spec = importlib.util.spec_from_file_location('case_mod', path)
|
|
40
|
+
mod = importlib.util.module_from_spec(spec)
|
|
41
|
+
spec.loader.exec_module(mod)
|
|
42
|
+
return mod
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def case_group(mod):
|
|
46
|
+
g = str(getattr(mod, 'PRECOND', 'anonymous') or 'anonymous')
|
|
47
|
+
return g if g in GROUP_ORDER else 'anonymous'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def order_files(dir_):
|
|
51
|
+
"""返回 [(filename, group)],按 组序→文件名 排序;只 import 一次读 PRECOND。"""
|
|
52
|
+
files = sorted(f for f in os.listdir(dir_) if f.endswith('.py') and not f.startswith('_'))
|
|
53
|
+
tagged = []
|
|
54
|
+
for f in files:
|
|
55
|
+
try:
|
|
56
|
+
mod = load_case(os.path.join(dir_, f))
|
|
57
|
+
tagged.append((f, case_group(mod)))
|
|
58
|
+
except Exception:
|
|
59
|
+
tagged.append((f, 'anonymous')) # import 失败留给 run_one 报 FAIL,不在此拦截
|
|
60
|
+
tagged.sort(key=lambda x: (GROUP_ORDER[x[1]], x[0]))
|
|
61
|
+
return tagged
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _scene_evidence(d, n=8):
|
|
65
|
+
# FAIL 现场证据: 当前页面 Text 摘要(进 results.msg, 供下轮 LLM 直读省重取证)
|
|
66
|
+
try:
|
|
67
|
+
ts = [(c.getText() or '')[:12] for c in (d.find_all_components(BY.type('Text')) or [])]
|
|
68
|
+
ts = [t for t in ts if t][:n]
|
|
69
|
+
return ' | 现场Text: ' + '/'.join(ts) if ts else ' | 现场Text: (空)'
|
|
70
|
+
except Exception:
|
|
71
|
+
return ''
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_one(d, mod_path, name):
|
|
75
|
+
mod = load_case(mod_path)
|
|
76
|
+
t0 = time.time()
|
|
77
|
+
try:
|
|
78
|
+
if hasattr(mod, 'process_case'):
|
|
79
|
+
sampler = None
|
|
80
|
+
_sample_state = None
|
|
81
|
+
try:
|
|
82
|
+
os.environ['FORGE_CASE'] = name
|
|
83
|
+
if SHOT_DIR: # 过程采样:每 2.5s 一帧(<case>_pNN.jpeg),执行过程关键画面自动留存
|
|
84
|
+
import threading
|
|
85
|
+
state = {'n': 0, 'stop': False}
|
|
86
|
+
_sample_state = state
|
|
87
|
+
|
|
88
|
+
def _sample():
|
|
89
|
+
import time as _t
|
|
90
|
+
while not state['stop']:
|
|
91
|
+
_t.sleep(2.5)
|
|
92
|
+
if state['stop']:
|
|
93
|
+
break
|
|
94
|
+
state['n'] += 1
|
|
95
|
+
try:
|
|
96
|
+
_shot(d, os.path.join(SHOT_DIR, f"{name}_p{state['n']:02d}.jpeg"), f'proc{state["n"]:02d}')
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
sampler = threading.Thread(target=_sample, daemon=True)
|
|
100
|
+
sampler.start()
|
|
101
|
+
mod.process_case(d)
|
|
102
|
+
if SHOT_DIR:
|
|
103
|
+
_shot(d, os.path.join(SHOT_DIR, name + '_end.jpeg'), 'end-shot')
|
|
104
|
+
except Exception as _ex:
|
|
105
|
+
if SHOT_DIR:
|
|
106
|
+
_shot(d, os.path.join(SHOT_DIR, name + '_fail.jpeg'), 'fail-shot')
|
|
107
|
+
raise type(_ex)(str(_ex) + _scene_evidence(d)) from None
|
|
108
|
+
finally:
|
|
109
|
+
_sample_state and _sample_state.__setitem__('stop', True)
|
|
110
|
+
os.environ.pop('FORGE_CASE', None)
|
|
111
|
+
else: # devicetest 风格兜底:实例化伪 TestCase
|
|
112
|
+
cls = None
|
|
113
|
+
for attr in dir(mod):
|
|
114
|
+
obj = getattr(mod, attr)
|
|
115
|
+
if isinstance(obj, type) and hasattr(obj, 'process') and attr != 'TestCase':
|
|
116
|
+
cls = obj
|
|
117
|
+
break
|
|
118
|
+
if cls is None:
|
|
119
|
+
return name, 'SKIP', 'no process func', 0
|
|
120
|
+
|
|
121
|
+
# devicetest TestCase.__init__(TAG, controllers) —— 直跑时跳过基类构造
|
|
122
|
+
inst = object.__new__(cls)
|
|
123
|
+
inst.driver = d
|
|
124
|
+
inst.device1 = d.device if hasattr(d, 'device') else None
|
|
125
|
+
inst.TAG = name
|
|
126
|
+
# 直跑跳过 __init__:软断言收集器等实例属性需补默认值
|
|
127
|
+
if hasattr(cls, '_soft_check') and not hasattr(inst, '_errors'):
|
|
128
|
+
inst._errors = []
|
|
129
|
+
# setup 也在 try/finally 内:setup 抛异常时 teardown 仍执行
|
|
130
|
+
try:
|
|
131
|
+
if hasattr(cls, 'setup'):
|
|
132
|
+
cls.setup(inst)
|
|
133
|
+
try:
|
|
134
|
+
os.environ['FORGE_CASE'] = name # 分步截图命名用
|
|
135
|
+
cls.process(inst)
|
|
136
|
+
if SHOT_DIR:
|
|
137
|
+
_shot(d, os.path.join(SHOT_DIR, f'{name}_end.jpeg'), 'end-shot')
|
|
138
|
+
except Exception:
|
|
139
|
+
if SHOT_DIR:
|
|
140
|
+
_shot(d, os.path.join(SHOT_DIR, f'{name}_fail.jpeg'), 'fail-shot')
|
|
141
|
+
raise
|
|
142
|
+
finally:
|
|
143
|
+
os.environ.pop('FORGE_CASE', None)
|
|
144
|
+
if hasattr(cls, 'teardown'):
|
|
145
|
+
try:
|
|
146
|
+
cls.teardown(inst)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
return name, 'PASS', '', round(time.time() - t0, 1)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
tb = traceback.format_exc().strip().split('\n')
|
|
152
|
+
msg = f'{e} | {tb[-1][:120] if len(tb) > 1 else ""}'
|
|
153
|
+
return name, 'FAIL', msg, round(time.time() - t0, 1)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
SHOT_DIR = None # 本次运行的截图目录(main 里初始化)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------- 产品代码变更检测(增量重试回归门禁) ----------------
|
|
160
|
+
# 背景:--retry-failed 只重跑 FAIL/SKIP,PASS 保留原结果;若上一轮之后修改了产品代码
|
|
161
|
+
# (entry/src/main/ets),旧 PASS 可能已回归(实证:Profiles TC09/TC14 PASS→FAIL 靠全量重跑才抓到)。
|
|
162
|
+
# 门禁:结果侧车 results.meta.json 记录 srcHash;增量重试前重算,不一致 → 自动升级为全量重跑。
|
|
163
|
+
|
|
164
|
+
def _find_src_root():
|
|
165
|
+
"""定位被测工程 entry/src/main/ets:FORGE_SRC_DIR 优先;否则在父/祖父目录的
|
|
166
|
+
*Ohos|ohos 兄弟工程中,按 forge 目录名令牌匹配(timer-hypium→TimerOhos);
|
|
167
|
+
匹配失败且候选不唯一时返回 None(门禁将保守全量,宁可多跑不漏回归)。"""
|
|
168
|
+
env = os.environ.get('FORGE_SRC_DIR')
|
|
169
|
+
if env and os.path.isdir(env):
|
|
170
|
+
return env
|
|
171
|
+
import re as _re
|
|
172
|
+
here = os.path.abspath(os.getcwd())
|
|
173
|
+
|
|
174
|
+
def _norm(s):
|
|
175
|
+
s = s.lower()
|
|
176
|
+
changed = True
|
|
177
|
+
while changed:
|
|
178
|
+
changed = False
|
|
179
|
+
for suf in ('-ohos', 'ohos', '-hypium', 'hypium', '-forge', 'forge', '-migration', 'migration'):
|
|
180
|
+
if s.endswith(suf) and len(s) > len(suf):
|
|
181
|
+
s = s[:-len(suf)]
|
|
182
|
+
changed = True
|
|
183
|
+
return s
|
|
184
|
+
|
|
185
|
+
token = _norm(_re.split(r'[-_.]', os.path.basename(here))[0])
|
|
186
|
+
if token in ('hypium', 'tmp', 'testcases', 'forge', ''): # <X>-migration/hypium_forge → 用父目录令牌
|
|
187
|
+
token = _norm(_re.split(r'[-_.]', os.path.basename(os.path.dirname(here)))[0])
|
|
188
|
+
cands = [] # (src_root, 归一化工程名)
|
|
189
|
+
for base in (os.path.dirname(here), os.path.dirname(os.path.dirname(here))):
|
|
190
|
+
if not base or not os.path.isdir(base):
|
|
191
|
+
continue
|
|
192
|
+
for d in sorted(os.listdir(base)):
|
|
193
|
+
if d.endswith('Ohos') or d == 'ohos':
|
|
194
|
+
cand = os.path.join(base, d, 'entry', 'src', 'main', 'ets')
|
|
195
|
+
if os.path.isdir(cand):
|
|
196
|
+
cands.append((cand, _norm(d)))
|
|
197
|
+
if not cands:
|
|
198
|
+
return None
|
|
199
|
+
if len(cands) == 1:
|
|
200
|
+
return cands[0][0]
|
|
201
|
+
for cand, dn in cands:
|
|
202
|
+
if dn == token or dn.startswith(token) or token.startswith(dn):
|
|
203
|
+
return cand
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def src_tree_hash():
|
|
208
|
+
"""entry/src/main/ets 下全部 .ets/.ts 内容哈希(排序后串联),产品代码指纹。"""
|
|
209
|
+
root = _find_src_root()
|
|
210
|
+
if not root:
|
|
211
|
+
return None
|
|
212
|
+
h = hashlib.sha256()
|
|
213
|
+
for dirpath, dirs, files in os.walk(root):
|
|
214
|
+
dirs[:] = sorted(d for d in dirs if d not in ('build', 'oh_modules', '.preview'))
|
|
215
|
+
for fn in sorted(files):
|
|
216
|
+
if not fn.endswith(('.ets', '.ts')):
|
|
217
|
+
continue
|
|
218
|
+
p = os.path.join(dirpath, fn)
|
|
219
|
+
h.update(os.path.relpath(p, root).encode())
|
|
220
|
+
try:
|
|
221
|
+
h.update(open(p, 'rb').read())
|
|
222
|
+
except OSError:
|
|
223
|
+
return None
|
|
224
|
+
return h.hexdigest()[:16]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _meta_path(result_path):
|
|
228
|
+
return os.path.join(os.path.dirname(os.path.abspath(result_path)) or '.',
|
|
229
|
+
'results.meta.json')
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _read_prev_src_hash(result_path):
|
|
233
|
+
mp = _meta_path(result_path)
|
|
234
|
+
if os.path.exists(mp):
|
|
235
|
+
try:
|
|
236
|
+
return json.load(open(mp, encoding='utf-8')).get('srcHash')
|
|
237
|
+
except Exception:
|
|
238
|
+
return None
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _write_src_meta(result_path, src_hash, mode):
|
|
243
|
+
if not src_hash:
|
|
244
|
+
return
|
|
245
|
+
try:
|
|
246
|
+
mp = _meta_path(result_path)
|
|
247
|
+
json.dump({'srcHash': src_hash, 'ts': time.time(), 'mode': mode},
|
|
248
|
+
open(mp, 'w', encoding='utf-8'), indent=1)
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _init_shot_dir():
|
|
255
|
+
global SHOT_DIR
|
|
256
|
+
import datetime
|
|
257
|
+
SHOT_DIR = os.path.join('reports', datetime.datetime.now().strftime('%Y-%m-%d'),
|
|
258
|
+
datetime.datetime.now().strftime('%H-%M-%S'))
|
|
259
|
+
os.makedirs(SHOT_DIR, exist_ok=True)
|
|
260
|
+
os.environ['FORGE_SHOT_DIR'] = SHOT_DIR # 分步截图(step_shot)读取
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
HDC_BIN = '/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc'
|
|
264
|
+
DEVICE_SN = None # main() 里从 args.device 赋值
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _shot(d, path, label='shot'):
|
|
268
|
+
"""截图(尽力而为,绝不影响结果记录):d.screenshot 优先,失败走 hdc snapshot_display。"""
|
|
269
|
+
os.makedirs(os.path.dirname(path) if os.path.dirname(path) else '.', exist_ok=True)
|
|
270
|
+
try:
|
|
271
|
+
d.screenshot(path)
|
|
272
|
+
print(f' [{label}] {path}')
|
|
273
|
+
return
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
try:
|
|
277
|
+
import subprocess as _sp
|
|
278
|
+
dev = DEVICE_SN or os.environ.get('FORGE_DEVICE', '127.0.0.1:5555')
|
|
279
|
+
remote = f'/data/local/tmp/_forge_shot.jpeg'
|
|
280
|
+
_sp.run([HDC_BIN, '-t', dev, 'shell', 'snapshot_display', '-f', remote],
|
|
281
|
+
capture_output=True, timeout=10)
|
|
282
|
+
_sp.run([HDC_BIN, '-t', dev, 'file', 'recv', remote, path],
|
|
283
|
+
capture_output=True, timeout=15)
|
|
284
|
+
_sp.run([HDC_BIN, '-t', dev, 'shell', 'rm', '-f', remote],
|
|
285
|
+
capture_output=True, timeout=5)
|
|
286
|
+
print(f' [{label}] {path} (hdc)')
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
print(f' [{label}] 不可用: {exc}')
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main():
|
|
292
|
+
ap = argparse.ArgumentParser()
|
|
293
|
+
ap.add_argument('--case', default=None)
|
|
294
|
+
ap.add_argument('--all', action='store_true')
|
|
295
|
+
ap.add_argument('--dir', default='testcases')
|
|
296
|
+
ap.add_argument('--device', default='127.0.0.1:5555')
|
|
297
|
+
ap.add_argument('--retry-failed', action='store_true',
|
|
298
|
+
help='增量重试:只重跑 results.json 中 FAIL/SKIP 条(PASS 保留原结果,合并写出);'
|
|
299
|
+
'产品代码(entry/src/main/ets)较上轮有变更时自动升级为全量重跑')
|
|
300
|
+
ap.add_argument('--force-incremental', action='store_true',
|
|
301
|
+
help='跳过产品代码变更门禁,强制增量(仅测试脚本变更时使用)')
|
|
302
|
+
args = ap.parse_args()
|
|
303
|
+
global DEVICE_SN
|
|
304
|
+
DEVICE_SN = args.device
|
|
305
|
+
sys.path.insert(0, os.path.abspath(args.dir))
|
|
306
|
+
|
|
307
|
+
if args.case:
|
|
308
|
+
ordered = [(f'{args.case}.py', 'anonymous')]
|
|
309
|
+
elif args.retry_failed:
|
|
310
|
+
prev = os.path.join(os.path.abspath(args.dir), '..', 'results.json')
|
|
311
|
+
if not os.path.exists(prev):
|
|
312
|
+
print('--retry-failed 需要 results.json'); sys.exit(1)
|
|
313
|
+
# ---- 回归门禁:产品代码变更 → 自动全量(TD-019)----
|
|
314
|
+
cur_hash = src_tree_hash()
|
|
315
|
+
if not args.force_incremental:
|
|
316
|
+
prev_hash = _read_prev_src_hash(prev)
|
|
317
|
+
if cur_hash is None:
|
|
318
|
+
print('⚠ 未定位到 entry/src/main/ets(可用 FORGE_SRC_DIR 指定),无法判定产品代码变更 → 保守全量重跑')
|
|
319
|
+
args.all = True
|
|
320
|
+
elif prev_hash is None:
|
|
321
|
+
print('⚠ results.meta.json 无上轮 srcHash 记录(旧版产物)→ 保守全量重跑(本轮后建立指纹)')
|
|
322
|
+
args.all = True
|
|
323
|
+
elif cur_hash != prev_hash:
|
|
324
|
+
print(f'⚠ 产品代码已变更(srcHash {prev_hash} → {cur_hash}):旧 PASS 可能回归 → 自动升级为全量重跑')
|
|
325
|
+
print(' (确认仅测试脚本变更可加 --force-incremental 强制增量)')
|
|
326
|
+
args.all = True
|
|
327
|
+
prev_res = {str(r.get('case')).split('_')[0]: r
|
|
328
|
+
for r in json.load(open(prev, encoding='utf-8'))}
|
|
329
|
+
all_ordered = order_files(args.dir)
|
|
330
|
+
if args.all: # 门禁升级为全量:不保留旧 PASS
|
|
331
|
+
ordered = all_ordered
|
|
332
|
+
print(f'全量重跑(产品代码变更门禁): {len(ordered)} 条')
|
|
333
|
+
else:
|
|
334
|
+
ordered = [(f, g) for f, g in all_ordered
|
|
335
|
+
if f[:-3].split('_')[0] not in prev_res
|
|
336
|
+
or prev_res[f[:-3].split('_')[0]].get('status') != 'PASS']
|
|
337
|
+
keep = [prev_res[f[:-3].split('_')[0]] for f, g in all_ordered
|
|
338
|
+
if f[:-3].split('_')[0] in prev_res
|
|
339
|
+
and prev_res[f[:-3].split('_')[0]].get('status') == 'PASS']
|
|
340
|
+
print(f'增量重试: 重跑 {len(ordered)} 条 / 保留 PASS {len(keep)} 条(srcHash 未变: {cur_hash})')
|
|
341
|
+
globals()['_RETRY_KEEP'] = keep
|
|
342
|
+
elif args.all:
|
|
343
|
+
ordered = order_files(args.dir)
|
|
344
|
+
else:
|
|
345
|
+
print('指定 --case X 或 --all(或 --retry-failed)'); sys.exit(1)
|
|
346
|
+
|
|
347
|
+
_init_shot_dir()
|
|
348
|
+
os.environ['FORGE_DEVICE'] = args.device # launch(want/reset) 的 hdc 透传用
|
|
349
|
+
|
|
350
|
+
d = UiDriver.connect(device_sn=args.device)
|
|
351
|
+
results = []
|
|
352
|
+
cur_group = None
|
|
353
|
+
for f, grp in ordered:
|
|
354
|
+
name = f[:-3]
|
|
355
|
+
if grp != cur_group: # 组切换:首启组排尾,此前组状态不被回头破坏
|
|
356
|
+
cur_group = grp
|
|
357
|
+
print(f'\n────── 前置组 [{grp}] 开始({grp} 组内软隔离共享前置)──────', flush=True)
|
|
358
|
+
os.environ['FORGE_GROUP_FIRST'] = '1'
|
|
359
|
+
print(f'▶ {name} ...', flush=True)
|
|
360
|
+
n, status, msg, dur = run_one(d, os.path.join(args.dir, f), name)
|
|
361
|
+
os.environ.pop('FORGE_GROUP_FIRST', None)
|
|
362
|
+
print(f' {status} ({dur}s) {msg[:110]}', flush=True)
|
|
363
|
+
results.append({'case': n, 'status': status, 'msg': msg, 'dur': dur, 'group': grp})
|
|
364
|
+
time.sleep(2)
|
|
365
|
+
|
|
366
|
+
results = globals().get('_RETRY_KEEP', []) + results
|
|
367
|
+
p = sum(1 for r in results if r['status'] == 'PASS')
|
|
368
|
+
f_ = sum(1 for r in results if r['status'] == 'FAIL')
|
|
369
|
+
print(f'\n══════ 直跑汇总: PASS={p} FAIL={f_} SKIP={len(results)-p-f_} / {len(results)} ══════')
|
|
370
|
+
from collections import Counter
|
|
371
|
+
print('分组: ' + ', '.join(f'{g}={c}' for g, c in Counter(r['group'] for r in results).items()))
|
|
372
|
+
# 同族错误聚类(分诊三律①:同消息视为同族,一次定案禁逐条实验)
|
|
373
|
+
fails = [r for r in results if r['status'] == 'FAIL']
|
|
374
|
+
if fails:
|
|
375
|
+
import re as _re
|
|
376
|
+
def _sig(m):
|
|
377
|
+
head = (m or '').split(' | ')[0]
|
|
378
|
+
return _re.sub(r'[::].+', ': X', head)[:60]
|
|
379
|
+
clus = Counter(_sig(r.get('msg')) for r in fails)
|
|
380
|
+
print('同族聚类: ' + ' ; '.join(f'{c}×[{k}]' for k, c in clus.most_common(4)))
|
|
381
|
+
out = 'results.json' if (args.all or args.retry_failed) else 'results-%s.json' % (args.case or 'single').split('_')[0]
|
|
382
|
+
if os.path.exists(out): # 修复轮重跑前保留上一轮原始记录(自愈边界:修≠覆盖历史)
|
|
383
|
+
import shutil as _sh
|
|
384
|
+
_sh.copy(out, out.replace('.json', '.prev.json'))
|
|
385
|
+
json.dump(results, open(out, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
|
|
386
|
+
if args.all or args.retry_failed: # 全量/合并结果写入后记录产品代码指纹(供下轮增量门禁比对)
|
|
387
|
+
_write_src_meta(out, src_tree_hash(), 'full' if args.all else 'retry')
|
|
388
|
+
print('明细: ' + out)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
if __name__ == '__main__':
|
|
392
|
+
main()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
run_parallel.py — 多设备并发直跑器(N 设备 × 1 线程/设备)。
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python3 run_parallel.py --dir testcases --devices 127.0.0.1:5555,127.0.0.1:5556,127.0.0.1:5557
|
|
7
|
+
可选: --filter-prefix F01(只跑某前缀)/ --results results.json
|
|
8
|
+
|
|
9
|
+
设计: 每设备一个常驻线程 + 独立 UiDriver(hypium 驱动线程安全边界内),
|
|
10
|
+
用例按 round-robin 分发;单例失败不中断;汇总 per-case 耗时与总时长。
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import importlib.util
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import queue
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import traceback
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
from hypium import UiDriver
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_case(path):
|
|
27
|
+
spec = importlib.util.spec_from_file_location(os.path.basename(path)[:-3], path)
|
|
28
|
+
mod = importlib.util.module_from_spec(spec)
|
|
29
|
+
spec.loader.exec_module(mod)
|
|
30
|
+
return mod
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_case_on(d, mod_path, name):
|
|
34
|
+
mod = load_case(mod_path)
|
|
35
|
+
t0 = time.time()
|
|
36
|
+
try:
|
|
37
|
+
cls = None
|
|
38
|
+
for attr in dir(mod):
|
|
39
|
+
obj = getattr(mod, attr)
|
|
40
|
+
if isinstance(obj, type) and hasattr(obj, 'process') and attr != 'TestCase':
|
|
41
|
+
cls = obj
|
|
42
|
+
break
|
|
43
|
+
inst = object.__new__(cls)
|
|
44
|
+
inst.driver = d
|
|
45
|
+
inst.device1 = None
|
|
46
|
+
inst.TAG = name
|
|
47
|
+
if hasattr(cls, 'setup'):
|
|
48
|
+
cls.setup(inst)
|
|
49
|
+
try:
|
|
50
|
+
cls.process(inst)
|
|
51
|
+
finally:
|
|
52
|
+
if hasattr(cls, 'teardown'):
|
|
53
|
+
try:
|
|
54
|
+
cls.teardown(inst)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
return 'PASS', '', round(time.time() - t0, 1)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
tb = traceback.format_exc().strip().split('\n')
|
|
60
|
+
return 'FAIL', f'{e} | {tb[-1][:100] if len(tb) > 1 else ""}', round(time.time() - t0, 1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def worker(dev_sn, case_q, results, lock):
|
|
64
|
+
try:
|
|
65
|
+
d = UiDriver.connect(device_sn=dev_sn)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
with lock:
|
|
68
|
+
results.append({'case': f'WORKER-{dev_sn}', 'status': 'ERROR', 'msg': str(e)[:100], 'dur': 0})
|
|
69
|
+
return
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
name, path = case_q.get_nowait()
|
|
73
|
+
except queue.Empty:
|
|
74
|
+
return
|
|
75
|
+
status, msg, dur = run_case_on(d, path, name)
|
|
76
|
+
with lock:
|
|
77
|
+
results.append({'case': name, 'status': status, 'msg': msg, 'dur': dur, 'device': dev_sn})
|
|
78
|
+
print(f'[{dev_sn}] {name}: {status} ({dur}s) {msg[:60]}', flush=True)
|
|
79
|
+
case_q.task_done()
|
|
80
|
+
time.sleep(2)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main():
|
|
84
|
+
ap = argparse.ArgumentParser()
|
|
85
|
+
ap.add_argument('--dir', default='testcases')
|
|
86
|
+
ap.add_argument('--devices', required=True, help='逗号分隔设备 SN')
|
|
87
|
+
ap.add_argument('--filter-prefix', default=None)
|
|
88
|
+
ap.add_argument('--results', default='results_parallel.json')
|
|
89
|
+
args = ap.parse_args()
|
|
90
|
+
|
|
91
|
+
sys.path.insert(0, os.path.abspath(args.dir))
|
|
92
|
+
files = sorted(f for f in os.listdir(args.dir) if f.endswith('.py') and not f.startswith('_'))
|
|
93
|
+
if args.filter_prefix:
|
|
94
|
+
files = [f for f in files if f.startswith(args.filter_prefix)]
|
|
95
|
+
if not files:
|
|
96
|
+
print('无用例'); sys.exit(1)
|
|
97
|
+
|
|
98
|
+
case_q = queue.Queue()
|
|
99
|
+
for f in files:
|
|
100
|
+
case_q.put((f[:-3], os.path.join(args.dir, f)))
|
|
101
|
+
|
|
102
|
+
devices = [d.strip() for d in args.devices.split(',') if d.strip()]
|
|
103
|
+
results, lock = [], threading.Lock()
|
|
104
|
+
t0 = time.time()
|
|
105
|
+
threads = [threading.Thread(target=worker, args=(dsn, case_q, results, lock)) for dsn in devices]
|
|
106
|
+
for t in threads:
|
|
107
|
+
t.start()
|
|
108
|
+
for t in threads:
|
|
109
|
+
t.join()
|
|
110
|
+
total = round(time.time() - t0, 1)
|
|
111
|
+
|
|
112
|
+
p = sum(1 for r in results if r['status'] == 'PASS')
|
|
113
|
+
f_ = sum(1 for r in results if r['status'] == 'FAIL')
|
|
114
|
+
durs = [r['dur'] for r in results if r['status'] in ('PASS', 'FAIL')]
|
|
115
|
+
print(f'\n══════ 并发汇总: PASS={p} FAIL={f_} / {len(results)} | 总时长 {total}s '
|
|
116
|
+
f'| 单例均值 {sum(durs)/len(durs):.1f}s | {len(devices)} 设备并发 ══════')
|
|
117
|
+
json.dump(results, open(args.results, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == '__main__':
|
|
121
|
+
main()
|