@harmonyos-arkts/d2h 0.1.6 → 0.1.8

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.
@@ -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(2.5)
136
+ _t.sleep(interval)
92
137
  if state['stop']:
93
138
  break
94
- state['n'] += 1
95
139
  try:
96
- _shot(d, os.path.join(SHOT_DIR, f"{name}_p{state['n']:02d}.jpeg"), f'proc{state["n"]:02d}')
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 _read_prev_src_hash(result_path):
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')).get('srcHash')
355
+ return json.load(open(mp, encoding='utf-8'))
237
356
  except Exception:
238
- return None
239
- return None
357
+ return {}
358
+ return {}
240
359
 
241
360
 
242
- def _write_src_meta(result_path, src_hash, mode):
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
- json.dump({'srcHash': src_hash, 'ts': time.time(), 'mode': mode},
248
- open(mp, 'w', encoding='utf-8'), indent=1)
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
- global DEVICE_SN
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
- # ---- 回归门禁:产品代码变更 → 自动全量(TD-019)----
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
- print(f'⚠ 产品代码已变更(srcHash {prev_hash} → {cur_hash}):旧 PASS 可能回归 → 自动升级为全量重跑')
325
- print(' (确认仅测试脚本变更可加 --force-incremental 强制增量)')
326
- args.all = True
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
- print(f'增量重试: 重跑 {len(ordered)} 条 / 保留 PASS {len(keep)} 条(srcHash 未变: {cur_hash})')
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 或 --all(或 --retry-failed)'); sys.exit(1)
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
- n, status, msg, dur = run_one(d, os.path.join(args.dir, f), name)
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(2)
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
- _write_src_meta(out, src_tree_hash(), 'full' if args.all else 'retry')
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__':