@harmonyos-arkts/d2h 0.1.5 → 0.1.7
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 +19 -18
- package/agents/android-to-hmos-00-orchestrator.md +1 -1
- package/agents/d2h.md +101 -285
- package/dist/bin/install-opencode.js +9 -4
- package/dist/src/install-opencode.js +20 -1
- package/dist/src/install-vscode.js +6 -3
- package/dist/src/metrics-plugin.js +20 -0
- package/dist/src/toolchain.js +16 -12
- package/package.json +1 -1
- package/scripts/check-environment.mjs +1 -2
- package/skills/d2h-hypium-forge/SKILL.md +59 -2
- package/skills/d2h-hypium-forge/scripts/_common_template.py +85 -40
- package/skills/d2h-hypium-forge/scripts/analyze_results.py +11 -2
- package/skills/d2h-hypium-forge/scripts/probe.py +197 -0
- package/skills/d2h-hypium-forge/scripts/run_direct.py +464 -33
- package/skills/d2h-page-develop/SKILL.md +112 -0
- package/skills/d2h-page-develop/agents/openai.yaml +7 -0
- package/skills/d2h-page-spec-generate/SKILL.md +206 -0
- package/skills/d2h-page-spec-generate/scripts/build_batch_report.py +227 -0
- package/skills/managing-d2h-toolchain/SKILL.md +14 -13
- package/skills/managing-d2h-toolchain/scripts/manage-toolchain.mjs +98 -0
- package/skills/appgraph-rule-audit/SKILL.md +0 -77
- package/skills/appgraph-rule-audit/references/audit-contract.md +0 -47
- package/skills/appgraph-rule-audit/references/recommendation-schema.md +0 -41
- package/skills/appgraph-rule-audit/schemas/rule-opportunities.schema.json +0 -125
|
@@ -14,6 +14,16 @@ run_direct.py — 裸 hypium 直跑器(无 devicetest/xdevice 依赖)+ 前
|
|
|
14
14
|
用法:
|
|
15
15
|
单条: python3 run_direct.py --case F01_func_normal_001 [--dir testcases]
|
|
16
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 优先)
|
|
17
27
|
|
|
18
28
|
要求: 用例 .py 文件含 process_case(d) 顶层函数;或兼容 devicetest 风格(反射 process 方法)。
|
|
19
29
|
结果: 逐条打印 PASS/FAIL + 汇总,写 results.json
|
|
@@ -23,6 +33,7 @@ import hashlib
|
|
|
23
33
|
import importlib.util
|
|
24
34
|
import json
|
|
25
35
|
import os
|
|
36
|
+
import re
|
|
26
37
|
import sys
|
|
27
38
|
import time
|
|
28
39
|
import traceback
|
|
@@ -71,6 +82,39 @@ def _scene_evidence(d, n=8):
|
|
|
71
82
|
return ''
|
|
72
83
|
|
|
73
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
|
+
|
|
74
118
|
def run_one(d, mod_path, name):
|
|
75
119
|
mod = load_case(mod_path)
|
|
76
120
|
t0 = time.time()
|
|
@@ -80,20 +124,27 @@ def run_one(d, mod_path, name):
|
|
|
80
124
|
_sample_state = None
|
|
81
125
|
try:
|
|
82
126
|
os.environ['FORGE_CASE'] = name
|
|
83
|
-
if SHOT_DIR: # 过程采样:每 2.5s 一帧(<case>_pNN.jpeg
|
|
127
|
+
if SHOT_DIR: # 过程采样:每 2.5s 一帧(<case>_pNN.jpeg);连续相同帧去重(静态页 8 帧→1 帧)
|
|
84
128
|
import threading
|
|
85
|
-
state = {'n': 0, 'stop': False}
|
|
129
|
+
state = {'n': 0, 'stop': False, 'last': None}
|
|
86
130
|
_sample_state = state
|
|
87
131
|
|
|
88
132
|
def _sample():
|
|
89
133
|
import time as _t
|
|
134
|
+
interval = 3.5 if SHOT_MODE == 'fail' else 2.5 # fail 模式降频,减少 RPC 争抢
|
|
90
135
|
while not state['stop']:
|
|
91
|
-
_t.sleep(
|
|
136
|
+
_t.sleep(interval)
|
|
92
137
|
if state['stop']:
|
|
93
138
|
break
|
|
94
|
-
state['n'] += 1
|
|
95
139
|
try:
|
|
96
|
-
|
|
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
|
|
97
148
|
except Exception:
|
|
98
149
|
pass
|
|
99
150
|
sampler = threading.Thread(target=_sample, daemon=True)
|
|
@@ -153,7 +204,56 @@ def run_one(d, mod_path, name):
|
|
|
153
204
|
return name, 'FAIL', msg, round(time.time() - t0, 1)
|
|
154
205
|
|
|
155
206
|
|
|
156
|
-
SHOT_DIR = None # 本次运行的截图目录(main
|
|
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])} 个)')
|
|
157
257
|
|
|
158
258
|
|
|
159
259
|
# ---------------- 产品代码变更检测(增量重试回归门禁) ----------------
|
|
@@ -224,40 +324,124 @@ def src_tree_hash():
|
|
|
224
324
|
return h.hexdigest()[:16]
|
|
225
325
|
|
|
226
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
|
+
|
|
227
346
|
def _meta_path(result_path):
|
|
228
347
|
return os.path.join(os.path.dirname(os.path.abspath(result_path)) or '.',
|
|
229
348
|
'results.meta.json')
|
|
230
349
|
|
|
231
350
|
|
|
232
|
-
def
|
|
351
|
+
def _read_prev_meta(result_path):
|
|
233
352
|
mp = _meta_path(result_path)
|
|
234
353
|
if os.path.exists(mp):
|
|
235
354
|
try:
|
|
236
|
-
return json.load(open(mp, encoding='utf-8'))
|
|
355
|
+
return json.load(open(mp, encoding='utf-8'))
|
|
237
356
|
except Exception:
|
|
238
|
-
return
|
|
239
|
-
return
|
|
357
|
+
return {}
|
|
358
|
+
return {}
|
|
240
359
|
|
|
241
360
|
|
|
242
|
-
def
|
|
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):
|
|
243
393
|
if not src_hash:
|
|
244
394
|
return
|
|
245
395
|
try:
|
|
246
396
|
mp = _meta_path(result_path)
|
|
247
|
-
|
|
248
|
-
|
|
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)
|
|
249
401
|
except Exception:
|
|
250
402
|
pass
|
|
251
403
|
|
|
252
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
|
|
253
444
|
|
|
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
445
|
|
|
262
446
|
|
|
263
447
|
HDC_BIN = '/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc'
|
|
@@ -299,9 +483,119 @@ def main():
|
|
|
299
483
|
'产品代码(entry/src/main/ets)较上轮有变更时自动升级为全量重跑')
|
|
300
484
|
ap.add_argument('--force-incremental', action='store_true',
|
|
301
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
|
+
'作为最终质量门的完整截图证据')
|
|
302
509
|
args = ap.parse_args()
|
|
303
|
-
|
|
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
|
|
304
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)
|
|
305
599
|
sys.path.insert(0, os.path.abspath(args.dir))
|
|
306
600
|
|
|
307
601
|
if args.case:
|
|
@@ -310,7 +604,7 @@ def main():
|
|
|
310
604
|
prev = os.path.join(os.path.abspath(args.dir), '..', 'results.json')
|
|
311
605
|
if not os.path.exists(prev):
|
|
312
606
|
print('--retry-failed 需要 results.json'); sys.exit(1)
|
|
313
|
-
# ---- 回归门禁:产品代码变更 →
|
|
607
|
+
# ---- 回归门禁:产品代码变更 → 全量 或 范围回归(touched,TD-019+)----
|
|
314
608
|
cur_hash = src_tree_hash()
|
|
315
609
|
if not args.force_incremental:
|
|
316
610
|
prev_hash = _read_prev_src_hash(prev)
|
|
@@ -321,9 +615,28 @@ def main():
|
|
|
321
615
|
print('⚠ results.meta.json 无上轮 srcHash 记录(旧版产物)→ 保守全量重跑(本轮后建立指纹)')
|
|
322
616
|
args.all = True
|
|
323
617
|
elif cur_hash != prev_hash:
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
327
640
|
prev_res = {str(r.get('case')).split('_')[0]: r
|
|
328
641
|
for r in json.load(open(prev, encoding='utf-8'))}
|
|
329
642
|
all_ordered = order_files(args.dir)
|
|
@@ -334,34 +647,116 @@ def main():
|
|
|
334
647
|
ordered = [(f, g) for f, g in all_ordered
|
|
335
648
|
if f[:-3].split('_')[0] not in prev_res
|
|
336
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},保守并入)')
|
|
337
671
|
keep = [prev_res[f[:-3].split('_')[0]] for f, g in all_ordered
|
|
338
672
|
if f[:-3].split('_')[0] in prev_res
|
|
339
|
-
and prev_res[f[:-3].split('_')[0]].get('status') == 'PASS'
|
|
340
|
-
|
|
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)} 条')
|
|
341
676
|
globals()['_RETRY_KEEP'] = keep
|
|
342
|
-
elif args.all:
|
|
677
|
+
elif args.all or args.filter:
|
|
343
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)
|
|
344
683
|
else:
|
|
345
|
-
print('指定 --case X
|
|
684
|
+
print('指定 --case X / --all / --retry-failed / --filter'); sys.exit(1)
|
|
346
685
|
|
|
347
686
|
_init_shot_dir()
|
|
348
687
|
os.environ['FORGE_DEVICE'] = args.device # launch(want/reset) 的 hdc 透传用
|
|
349
688
|
|
|
350
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
|
+
|
|
351
703
|
results = []
|
|
352
704
|
cur_group = None
|
|
705
|
+
first_in_group = True
|
|
353
706
|
for f, grp in ordered:
|
|
354
707
|
name = f[:-3]
|
|
355
708
|
if grp != cur_group: # 组切换:首启组排尾,此前组状态不被回头破坏
|
|
356
709
|
cur_group = grp
|
|
710
|
+
first_in_group = True
|
|
357
711
|
print(f'\n────── 前置组 [{grp}] 开始({grp} 组内软隔离共享前置)──────', flush=True)
|
|
358
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)
|
|
359
722
|
print(f'▶ {name} ...', flush=True)
|
|
360
|
-
|
|
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()
|
|
361
730
|
os.environ.pop('FORGE_GROUP_FIRST', None)
|
|
731
|
+
first_in_group = False
|
|
362
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 保留全过程证据
|
|
363
735
|
results.append({'case': n, 'status': status, 'msg': msg, 'dur': dur, 'group': grp})
|
|
364
|
-
time.sleep(
|
|
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]}'
|
|
365
760
|
|
|
366
761
|
results = globals().get('_RETRY_KEEP', []) + results
|
|
367
762
|
p = sum(1 for r in results if r['status'] == 'PASS')
|
|
@@ -379,13 +774,49 @@ def main():
|
|
|
379
774
|
clus = Counter(_sig(r.get('msg')) for r in fails)
|
|
380
775
|
print('同族聚类: ' + ' ; '.join(f'{c}×[{k}]' for k, c in clus.most_common(4)))
|
|
381
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
|
|
382
796
|
if os.path.exists(out): # 修复轮重跑前保留上一轮原始记录(自愈边界:修≠覆盖历史)
|
|
383
797
|
import shutil as _sh
|
|
384
798
|
_sh.copy(out, out.replace('.json', '.prev.json'))
|
|
385
799
|
json.dump(results, open(out, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
|
|
386
|
-
if args.all or args.retry_failed: #
|
|
387
|
-
|
|
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
|
|
388
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)
|
|
389
820
|
|
|
390
821
|
|
|
391
822
|
if __name__ == '__main__':
|