@harmonyos-arkts/d2h 0.1.2 → 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.
Files changed (71) hide show
  1. package/README.md +78 -44
  2. package/agents/android-to-hmos-00-orchestrator.md +34 -28
  3. package/agents/d2h.md +266 -95
  4. package/dist/bin/install-opencode.js +137 -43
  5. package/dist/src/install-opencode.js +44 -0
  6. package/dist/src/install-vscode.js +226 -0
  7. package/dist/src/toolchain.js +83 -0
  8. package/docs/environment-check.md +86 -0
  9. package/opencode/agents.json +1 -1
  10. package/opencode/vscode-d2h-package.json +7 -0
  11. package/opencode/vscode-d2h-plugin.mjs +29 -0
  12. package/package.json +6 -4
  13. package/schemas/task-envelope.schema.json +58 -4
  14. package/schemas/task-plan.schema.json +281 -63
  15. package/scripts/check-environment.mjs +321 -0
  16. package/skills/android2hmos_resources_convert/SKILL.md +37 -19
  17. package/skills/appgraph-rule-audit/SKILL.md +24 -5
  18. package/skills/d2h-hypium-forge/SKILL.md +252 -0
  19. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -0
  20. package/skills/d2h-hypium-forge/references/official-api.md +135 -0
  21. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -0
  22. package/skills/d2h-hypium-forge/references/testability-template.md +71 -0
  23. package/skills/d2h-hypium-forge/scripts/_common_template.py +614 -0
  24. package/skills/d2h-hypium-forge/scripts/analyze_results.py +156 -0
  25. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -0
  26. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -0
  27. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -0
  28. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -0
  29. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -0
  30. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -0
  31. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -0
  32. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -0
  33. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -0
  34. package/skills/d2h-hypium-forge/scripts/run_direct.py +392 -0
  35. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -0
  36. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -0
  37. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -0
  38. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -0
  39. package/skills/d2h-utg-ai-generate/SKILL.md +128 -0
  40. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -0
  41. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -0
  42. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -0
  43. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -0
  44. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -0
  45. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -0
  46. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -0
  47. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -0
  48. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -0
  49. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -0
  50. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -0
  51. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -0
  52. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -0
  53. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -0
  54. package/skills/d2h-utg-static-generate/SKILL.md +73 -0
  55. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -0
  56. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -0
  57. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -0
  58. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -0
  59. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -0
  60. package/skills/harmonyos-migration-playbook/SKILL.md +30 -3
  61. package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +4 -4
  62. package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
  63. package/skills/harmonyos-migration-playbook/references/windows-environment.md +2 -2
  64. package/skills/maintaining-migration-report/SKILL.md +88 -99
  65. package/skills/maintaining-migration-report/references/final-acceptance.md +48 -0
  66. package/skills/maintaining-migration-report/references/source-provenance.md +66 -0
  67. package/skills/managing-d2h-toolchain/SKILL.md +23 -0
  68. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -0
  69. package/skills/managing-d2h-toolchain/toolchain.json +16 -0
  70. package/skills/preparing-migration-workspace/SKILL.md +76 -69
  71. package/skills/preparing-migration-workspace/toolchain.json +0 -43
@@ -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()