@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.
- 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/android2hmos_resources_convert/SKILL.md +37 -19
- package/skills/appgraph-rule-audit/SKILL.md +24 -5
- 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/SKILL.md +30 -3
- package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +4 -4
- package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
- package/skills/harmonyos-migration-playbook/references/windows-environment.md +2 -2
- package/skills/maintaining-migration-report/SKILL.md +88 -99
- package/skills/maintaining-migration-report/references/final-acceptance.md +48 -0
- package/skills/maintaining-migration-report/references/source-provenance.md +66 -0
- 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 +76 -69
- package/skills/preparing-migration-workspace/toolchain.json +0 -43
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# 可测试性需求(Design for Testability)— 模板与累积清单
|
|
2
|
+
|
|
3
|
+
> 调测中发现的「让 hypium 测试变慢/不准」的工程特征,反哺为对应用开发的需求。
|
|
4
|
+
> 每条格式:现象(实测) → 根因 → 可测试性需求(对开发的要求) → 收益。
|
|
5
|
+
|
|
6
|
+
## 累积清单(按工程验证时追加)
|
|
7
|
+
|
|
8
|
+
### DFT-001 控件必须预置稳定 ID
|
|
9
|
+
- 现象:无 .id() 控件用 BY.text 定位,文案随数据变化 → 脚本重试多轮、不稳定(实测:首个转化工程首轮全部超时)。
|
|
10
|
+
- 根因:ArkTS 开发普遍省略 .id(),uitest 只能按 text/type 匹配。
|
|
11
|
+
- 需求:**所有可交互控件(Button/Toggle/TextInput/入口 Image)必须写 .id()**,命名 `<语义>_<页面>`(如 btn_settings_index)。
|
|
12
|
+
- 收益:脚本零重试定位;用例与实现解耦(文案改了脚本不断)。
|
|
13
|
+
|
|
14
|
+
### DFT-002 条件渲染控件需可达路径
|
|
15
|
+
- 现象:`if(testMode)` 内控件默认态不存在,存在性断言误报失败(实测 START TEST 按钮)。
|
|
16
|
+
- 需求:条件功能的入口开关应在**入口页可达**(≤2 步操作),或提供 deeplink/want 参数直达。
|
|
17
|
+
- 收益:状态覆盖用例可自动执行,不依赖复杂前置链。
|
|
18
|
+
|
|
19
|
+
### DFT-003 运行时权限在模拟器的可预授性
|
|
20
|
+
- 现象:首启权限弹窗阻塞 BFS/用例执行(实测 RaiseToAnswer 电话权限)。
|
|
21
|
+
- 需求:应用不应在首帧同步弹多个权限弹窗;权限申请延后到功能触发点。
|
|
22
|
+
- 收益:precondition 批量预授后用例零弹窗干扰。
|
|
23
|
+
|
|
24
|
+
### DFT-004 登录需提供测试通道
|
|
25
|
+
- 现象:真实登录页在模拟器无法自动完成 → 登录后功能全部 BLOCKED。
|
|
26
|
+
- 需求:提供 mock 登录开关(want 参数/环境开关)或测试账号常量。
|
|
27
|
+
- 收益:登录态用例可全自动执行。
|
|
28
|
+
|
|
29
|
+
### DFT-005 首启弹窗需可编程处置
|
|
30
|
+
- 现象:隐私协议/引导页弹窗文案各异,逐个适配成本高。
|
|
31
|
+
- 需求:首启弹窗按钮文案使用标准词(同意并继续/跳过/我知道了);提供"不再提醒"。
|
|
32
|
+
- 收益:统一 dismiss 片段即可覆盖。
|
|
33
|
+
|
|
34
|
+
(新工程验证时按编号追加,注明来源工程与实测数据)
|
|
35
|
+
|
|
36
|
+
### DFT-006 HAP 更新必须卸载重装(模拟器缓存怪癖)
|
|
37
|
+
- 现象:`install -r` 同版本号替换后,注入的 .id 运行时全部丢失(abc 缓存不失效,实测 News 工程)。
|
|
38
|
+
- 需求/skill 规则:**注入 ID 重打包后一律 `hdc uninstall <bundle> && hdc install <hap>`**。
|
|
39
|
+
|
|
40
|
+
### DFT-007 部分 HAR 模块 .id 运行时丢弃(待官方解释)
|
|
41
|
+
- 现象:同一 HAP 内 commons/features-home 的 .id 生效,features-mine 的全部不生效(abc 字节串在、属性指令无效,跨 4 种注入位置复现)。
|
|
42
|
+
- 需求:**smart_check/smart_click 必须带 orig_target 文本兜底**(key 失败转文本/类型断言)。
|
|
43
|
+
- 来源:News 工程(2026-09-23 实测)。
|
|
44
|
+
|
|
45
|
+
### DFT-008 同控件多次注入会叠 id(仅首个生效)
|
|
46
|
+
- 现象:多条用例引用同一控件时注入器产生 `.id(a).id(b)`,运行时仅第一个可查。
|
|
47
|
+
- 需求/skill 规则:**用例引用统一到首注入 ID(`_1` 后缀规约)**;注入器应检测已有 tc_ id 并复用。
|
|
48
|
+
|
|
49
|
+
### DFT-009 Tab 切换的可靠路径
|
|
50
|
+
- 现象:BY.text 精确/模糊点击 Tab 均可能点到正文同名文本;比例坐标受 Tab 数量与手势条影响。
|
|
51
|
+
- 需求/skill 规则:**click_tab 用 find_all_components 取「最靠下的同名文本」的 bounds 中心 touch**(News/SchoolLife/CoolMall 2/3/4-Tab 全验证通过)。
|
|
52
|
+
|
|
53
|
+
### DFT-010 资源化文案使静态覆盖度失准
|
|
54
|
+
- 现象:CoolMall(70k 行)源码文案全部 `$r()` 资源化,scan 的交互点与运行时文本不匹配 → 静态覆盖度 0%。
|
|
55
|
+
- 需求/skill 规则:大型工程覆盖度评估降级为**运行时可达性口径**(用例通过率 + dump 文本对照);源码覆盖度仅对硬编码文案工程有效。
|
|
56
|
+
|
|
57
|
+
### DFT-011 用例间需间歇(teardown 冷启动时序)
|
|
58
|
+
- 现象:批量连跑时下一条 setup 的 start_app 未完成即断言(TC01 偶发失败)。
|
|
59
|
+
- skill 规则:批量执行循环 **sleep 3s** + launch 等待 2.5s 起步。
|
|
60
|
+
|
|
61
|
+
### DFT-012 输入法首用向导是隐藏前置(CoolMall 实测)
|
|
62
|
+
- 现象:input_text 前拉起「小艺输入法」声明弹窗 + 键盘布局向导(多步),挡住输入与提交按钮;向导期间点登录无效。
|
|
63
|
+
- skill 规则:precondition 增加 IME 预置步骤——检测并依次点掉「同意/下一步/完成」;输入后 **press_back 收键盘再点提交按钮**(否则按钮被键盘遮挡)。
|
|
64
|
+
|
|
65
|
+
### DFT-013 输入框定位用 BY.hint 不用 BY.text
|
|
66
|
+
- 现象:`BY.text('请输入手机号')` 匹配到的是 hint 展示文本节点而非 TextInput 本体,input_text 无效且无报错。
|
|
67
|
+
- skill 规则:**带占位符的输入框一律 `BY.hint('<placeholder>')` 定位**(hypium 专属匹配器)。
|
|
68
|
+
|
|
69
|
+
### DFT-014 设备访问 PC mock 需 rport(fport 是反向)
|
|
70
|
+
- 现象:`hdc fport` 是 PC→设备方向;设备 App 访问 127.0.0.1:8787 的 PC mock 必须用 `hdc rport tcp:8787 tcp:8787`(CoolMall BASE_URL 已预指 mock)。
|
|
71
|
+
- 附:mock 重启后 App 需 aa force-stop 冷启动才重新拉数据(失败态有缓存)。
|
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
"""d2h-hypium-forge 共享库(自动生成)
|
|
3
|
+
|
|
4
|
+
前置条件分组编排约定(TD-004):
|
|
5
|
+
需要登录态的用例文件顶部声明 PRECOND = 'logged_in'
|
|
6
|
+
首启态(bm clean)用例声明 PRECOND = 'first_launch'
|
|
7
|
+
缺省 PRECOND = 'anonymous'
|
|
8
|
+
run_direct 按 anonymous→logged_in→first_launch 分组排序(破坏性递增,clean 类排尾),
|
|
9
|
+
组内软隔离(launch() 默认 reset_data=False,preferences 保留→登录态保持)。
|
|
10
|
+
ensure_login 等 setup 必须【幂等】:先探测状态(抽屉用户名已就位)再决定是否走完整
|
|
11
|
+
UI 登录——组首一次完整 setup,组内后续直接复用(省重复登录/注册)。
|
|
12
|
+
组首条执行时环境变量 FORGE_GROUP_FIRST=1(可选做完整冷启验证)。
|
|
13
|
+
"""
|
|
14
|
+
import time
|
|
15
|
+
from hypium import *
|
|
16
|
+
from hypium.model import MatchPattern, UiParam
|
|
17
|
+
|
|
18
|
+
PACKAGE = "me.hackerchick.raisetoanswer"
|
|
19
|
+
ABILITY = "EntryAbility"
|
|
20
|
+
ENTRY_PAGE = "pages/Index"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── Level 0 统一引导引擎(TD-008):权限/隐私/广告/引导弹窗 + tab 归位,一揽子消化 ──
|
|
24
|
+
# 关卡模型:每关 = 探测特征文案集合 → 处置(点击命中项)→ 循环消化到无特征。
|
|
25
|
+
# app 级配置 GATES(按需增删);bootstrap 幂等(主页锚可见即成功),超时输出页面快照供分诊。
|
|
26
|
+
|
|
27
|
+
GATES = [
|
|
28
|
+
{"name": "privacy", "texts": ["同意并继续", "同意", "知道了", "我知道了"]},
|
|
29
|
+
{"name": "permission", "texts": ["仅在使用中允许", "使用应用时允许", "允许"]},
|
|
30
|
+
{"name": "ad", "texts": ["跳过", "关闭广告", "不再显示", "以后再说", "暂不"]},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
def _page_texts(d):
|
|
34
|
+
try:
|
|
35
|
+
return [(c.getText() or '') for c in (d.find_all_components(BY.type("Text")) or [])
|
|
36
|
+
if c.getText()]
|
|
37
|
+
except Exception:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
def bootstrap(d, gates=None, home_tab="", anchor_key="", total=60, log=print):
|
|
41
|
+
"""冷启到可用基座。流程:轮询消化全部弹窗关卡(一轮多点,直到无特征)→
|
|
42
|
+
tab 归位(点目标 tab + 验证锚)→ 主页锚可见返回 True。
|
|
43
|
+
anchor_key 是"基座就绪"的唯一判据(app 级配置,如目标 tab 的内容控件 id)。"""
|
|
44
|
+
gates = gates if gates is not None else GATES
|
|
45
|
+
all_texts = [t for g in gates for t in g["texts"]]
|
|
46
|
+
t0 = time.time()
|
|
47
|
+
dismissed = []
|
|
48
|
+
while time.time() - t0 < total:
|
|
49
|
+
# 1) 主页锚已可见 → 提前成功(幂等)
|
|
50
|
+
if anchor_key:
|
|
51
|
+
try:
|
|
52
|
+
if d.find_component(BY.key(anchor_key)) is not None:
|
|
53
|
+
if dismissed:
|
|
54
|
+
log(f" [L0] 弹窗消化: {dismissed}")
|
|
55
|
+
return True
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
# 2) 消化所有弹窗关卡:一轮内逐个点
|
|
59
|
+
hit_any = False
|
|
60
|
+
for t in all_texts:
|
|
61
|
+
if t == (home_tab or ""):
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
# 全类型探测(按钮常为 Button 非 Text——TD-008 实证:Text-only 扫描漏掉隐私页同意按钮)
|
|
65
|
+
if d.find_component(BY.text(t)) is not None:
|
|
66
|
+
d.touch(BY.text(t))
|
|
67
|
+
dismissed.append(t)
|
|
68
|
+
hit_any = True
|
|
69
|
+
time.sleep(1.2)
|
|
70
|
+
break # 点一个后重新扫(弹窗可能叠加/消失后露下一个)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
if hit_any:
|
|
74
|
+
continue
|
|
75
|
+
pts = _page_texts(d)
|
|
76
|
+
# 3) 无弹窗特征:tab 归位(点目标 tab,锚验证)
|
|
77
|
+
if home_tab and home_tab in pts:
|
|
78
|
+
if anchor_key:
|
|
79
|
+
try:
|
|
80
|
+
if d.find_component(BY.key(anchor_key)) is not None:
|
|
81
|
+
return True
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
try:
|
|
85
|
+
d.touch(BY.text(home_tab))
|
|
86
|
+
time.sleep(2.0)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
time.sleep(0.9)
|
|
90
|
+
# 超时:输出页面快照供分诊
|
|
91
|
+
log(f" [L0] ✗ bootstrap 超时 {total}s;页面文本快照: {_page_texts(d)[:15]}")
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── Level 0 前置三件套(TD-007:冷启自适应,用例不得自带)────────────────
|
|
96
|
+
# ① 首启弹窗 dismiss:多文案轮询(隐私/权限/引导),幂等无弹窗秒过
|
|
97
|
+
FIRST_RUN_DISMISS_TEXTS = ["同意并继续", "同意", "允许", "仅在使用中允许", "我知道了", "知道了", "暂不", "以后再说"]
|
|
98
|
+
|
|
99
|
+
def dismiss_first_run(d, rounds=3, gap=1.2):
|
|
100
|
+
for _ in range(rounds):
|
|
101
|
+
hit = False
|
|
102
|
+
for t in FIRST_RUN_DISMISS_TEXTS:
|
|
103
|
+
try:
|
|
104
|
+
c = d.find_component(BY.text(t))
|
|
105
|
+
if c is not None:
|
|
106
|
+
d.touch(BY.text(t)); time.sleep(gap); hit = True; break
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
if not hit:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# ② 首页 tab 归位:应用默认落在非目标 tab(如广场)时点回目标 tab(HOME_TAB 应用级配置)
|
|
113
|
+
def ensure_home_tab(d, tab_text, timeout=10):
|
|
114
|
+
t0 = time.time()
|
|
115
|
+
while time.time() - t0 < timeout:
|
|
116
|
+
try:
|
|
117
|
+
if d.find_component(BY.text(tab_text)) is not None:
|
|
118
|
+
d.touch(BY.text(tab_text)); time.sleep(1.5); return True
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
time.sleep(0.8)
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
# ③ 冷启三件套:launch 内自动执行——弹窗 dismiss + tab 归位(HOME_TAB 非空时)
|
|
125
|
+
HOME_TAB = "" # 应用级:主功能 tab 文案(如 "玩安卓");空串跳过归位
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def launch(d, want="", reset_data=False):
|
|
129
|
+
"""v3-①②(EXEC-034):want=启动参数('k=v;k2=v2' → hdc aa start --ps 透传,启动参数门控应用免人工补丁);
|
|
130
|
+
reset_data=True → bm clean -n 清应用数据(用例级状态重置,防跨用例泄漏)。"""
|
|
131
|
+
import os as _os, subprocess as _sp
|
|
132
|
+
d.stop_app(PACKAGE)
|
|
133
|
+
time.sleep(0.8)
|
|
134
|
+
if reset_data:
|
|
135
|
+
hdc = _os.environ.get("FORGE_HDC", "hdc")
|
|
136
|
+
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
137
|
+
try:
|
|
138
|
+
# DEVIATION-3: bm clean 不带 -d 时仅清 cache(-c),持久化不清,用例间状态泄漏
|
|
139
|
+
# (TC02 关 answer 残留致 TC03/08/09 连锁失败);本机 bm 无 -b 选项,-d 清数据
|
|
140
|
+
_sp.run([hdc, "-t", dev, "shell", "bm", "clean", "-n", PACKAGE, "-d"],
|
|
141
|
+
capture_output=True, timeout=15)
|
|
142
|
+
time.sleep(0.5)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
print(" [reset] bm clean 失败:", e)
|
|
145
|
+
if want:
|
|
146
|
+
hdc = _os.environ.get("FORGE_HDC", "hdc")
|
|
147
|
+
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
148
|
+
args = ["aa", "start", "-b", PACKAGE, "-a", ABILITY]
|
|
149
|
+
for kv in want.split(";"):
|
|
150
|
+
if "=" in kv:
|
|
151
|
+
k, v = kv.split("=", 1)
|
|
152
|
+
# DEVIATION-1: aa start 的 --ps 只接受空格分隔 key value,等号形式报
|
|
153
|
+
# "invalid number of parameters for option --ps" 导致带参启动全部失败
|
|
154
|
+
args += ["--ps", k, v]
|
|
155
|
+
# EXEC-038 修复:带参启动 3 轮重试 + 实测探活(FORGE_WANT_PROBE=key,可寻才算活)
|
|
156
|
+
probe = _os.environ.get("FORGE_WANT_PROBE", "")
|
|
157
|
+
for attempt in range(3):
|
|
158
|
+
try:
|
|
159
|
+
_sp.run([hdc, "-t", dev, "shell", "aa", "force-stop", PACKAGE], capture_output=True, timeout=10)
|
|
160
|
+
time.sleep(0.8)
|
|
161
|
+
_sp.run([hdc, "-t", dev, "shell"] + args, capture_output=True, timeout=15)
|
|
162
|
+
time.sleep(3.0)
|
|
163
|
+
if probe:
|
|
164
|
+
c = None
|
|
165
|
+
try:
|
|
166
|
+
c = d.find_component(BY.key(probe))
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
if c is None:
|
|
170
|
+
print(f" [want] 第{attempt+1}轮探活失败(key={probe}),重试")
|
|
171
|
+
time.sleep(1.5)
|
|
172
|
+
continue
|
|
173
|
+
_dismiss_first_run(d)
|
|
174
|
+
return
|
|
175
|
+
except Exception as e:
|
|
176
|
+
print(" [want] 带参启动异常,重试:", e)
|
|
177
|
+
time.sleep(1.5)
|
|
178
|
+
print(" [want] 3 轮未探活成功,继续交由断言暴露")
|
|
179
|
+
d.start_app(PACKAGE, ABILITY)
|
|
180
|
+
ok = bootstrap(d, home_tab=HOME_TAB, anchor_key=HOME_ANCHOR)
|
|
181
|
+
if not ok:
|
|
182
|
+
raise AssertionError("L0 bootstrap failed(弹窗/tab 未消化,见日志快照)")
|
|
183
|
+
time.sleep(2.5)
|
|
184
|
+
_dismiss_first_run(d)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _dismiss_first_run(d):
|
|
188
|
+
"""首启弹窗一键处置(权限/隐私协议)"""
|
|
189
|
+
for txt in ["同意并继续", "允许", "我知道了", "暂不", "以后再说", "同意"]:
|
|
190
|
+
try:
|
|
191
|
+
c = d.find_component(BY.text(txt))
|
|
192
|
+
if c:
|
|
193
|
+
c.click()
|
|
194
|
+
time.sleep(0.8)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def stop(d):
|
|
200
|
+
d.stop_app(PACKAGE)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def nav_to(d, page):
|
|
204
|
+
"""导航到目标页:入口页直接用;非入口页用返回键回根后再走用例自带链(由用例 steps 驱动)"""
|
|
205
|
+
if page == ENTRY_PAGE:
|
|
206
|
+
return
|
|
207
|
+
# 返回根页(最多 5 次)
|
|
208
|
+
for _ in range(5):
|
|
209
|
+
try:
|
|
210
|
+
if d.find_component(BY.key("__never__")) is None:
|
|
211
|
+
pass
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
d.press_back()
|
|
215
|
+
time.sleep(0.4)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
TAB_LABELS = ["首页", "视频", "我的"]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def click_tab(d, label):
|
|
222
|
+
"""底部 Tab 切换:定位文本→bounds 中心 touch(比例坐标兜底)"""
|
|
223
|
+
import time as _t
|
|
224
|
+
for _ in range(8):
|
|
225
|
+
try:
|
|
226
|
+
comps = d.find_all_components(BY.text(label))
|
|
227
|
+
if comps:
|
|
228
|
+
c = comps[-1] # 底部 tab 是同名文本中最靠下的
|
|
229
|
+
b = c.getBounds()
|
|
230
|
+
cx = (b.left + b.right) // 2
|
|
231
|
+
cy = (b.top + b.bottom) // 2
|
|
232
|
+
d.touch((int(cx), int(cy)))
|
|
233
|
+
_t.sleep(1.2)
|
|
234
|
+
return
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
_t.sleep(0.5)
|
|
238
|
+
idx = TAB_LABELS.index(label) if label in TAB_LABELS else 2
|
|
239
|
+
w, h = d.get_display_size()
|
|
240
|
+
d.touch((int(w * (idx + 0.5) / len(TAB_LABELS)), int(h * 0.945)))
|
|
241
|
+
time.sleep(1.2)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def click_text(d, txt, wait=6):
|
|
245
|
+
"""按可见文本点击(tab 名/中文按钮等无 ID 控件)。
|
|
246
|
+
先试 BY.key——无下划线的单词 id(如 fab)无法与文本区分(_is_id_like 局限),此处兜底。"""
|
|
247
|
+
import time as _t
|
|
248
|
+
try:
|
|
249
|
+
c = d.find_component(BY.key(txt))
|
|
250
|
+
if c:
|
|
251
|
+
c.click()
|
|
252
|
+
_t.sleep(1.0)
|
|
253
|
+
return
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
deadline = _t.time() + wait
|
|
257
|
+
while _t.time() < deadline:
|
|
258
|
+
try:
|
|
259
|
+
c = d.find_component(BY.text(txt, MatchPattern.CONTAINS))
|
|
260
|
+
if c:
|
|
261
|
+
c.click()
|
|
262
|
+
_t.sleep(1.0)
|
|
263
|
+
return
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
_t.sleep(0.5)
|
|
267
|
+
raise AssertionError("text not clickable: " + txt)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def click_type_first(d, ctype, wait=6):
|
|
271
|
+
"""点击指定类型的第一个组件(lineN:Type 引用的兜底点击)"""
|
|
272
|
+
import time as _t
|
|
273
|
+
deadline = _t.time() + wait
|
|
274
|
+
while _t.time() < deadline:
|
|
275
|
+
try:
|
|
276
|
+
comps = d.find_all_components(BY.type(ctype))
|
|
277
|
+
if comps:
|
|
278
|
+
b = comps[0].getBounds()
|
|
279
|
+
d.touch(((b.left + b.right) // 2, (b.top + b.bottom) // 2))
|
|
280
|
+
_t.sleep(1.2)
|
|
281
|
+
return
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
_t.sleep(0.5)
|
|
285
|
+
raise AssertionError("type not clickable: " + ctype)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def id_click(d, cid, wait=6):
|
|
289
|
+
comp = _find(d, cid, wait)
|
|
290
|
+
comp.click()
|
|
291
|
+
time.sleep(1.0)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def id_input(d, cid, text, wait=6):
|
|
295
|
+
comp = _find(d, cid, wait)
|
|
296
|
+
d.input_text(comp, text)
|
|
297
|
+
time.sleep(0.6)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _find(d, cid, wait=6):
|
|
301
|
+
import time as _t
|
|
302
|
+
deadline = _t.time() + wait
|
|
303
|
+
while _t.time() < deadline:
|
|
304
|
+
try:
|
|
305
|
+
c = d.find_component(BY.key(cid))
|
|
306
|
+
if c:
|
|
307
|
+
return c
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
try:
|
|
311
|
+
c = d.find_component(BY.id(cid))
|
|
312
|
+
if c:
|
|
313
|
+
return c
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
316
|
+
_t.sleep(0.5)
|
|
317
|
+
raise AssertionError("component not found: " + cid)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def smart_click(d, cid, txt=None, wait=6):
|
|
321
|
+
"""key 优先点击;失败且有 txt 时文本点击(mine 类 HAR 模块 ID 丢失边界)"""
|
|
322
|
+
import time as _t
|
|
323
|
+
deadline = _t.time() + wait
|
|
324
|
+
while _t.time() < deadline:
|
|
325
|
+
try:
|
|
326
|
+
c = d.find_component(BY.key(cid))
|
|
327
|
+
if c:
|
|
328
|
+
c.click()
|
|
329
|
+
_t.sleep(1.0)
|
|
330
|
+
return
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
_t.sleep(0.5)
|
|
334
|
+
if txt and txt.startswith("line"):
|
|
335
|
+
import re as _re
|
|
336
|
+
m = _re.match(r"line\d+:(\w+)", txt)
|
|
337
|
+
if m:
|
|
338
|
+
click_type_first(d, m.group(1), wait)
|
|
339
|
+
return
|
|
340
|
+
if txt:
|
|
341
|
+
click_text(d, txt, wait)
|
|
342
|
+
return
|
|
343
|
+
raise AssertionError("component not found: " + cid)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def scroll_text_exist(d, txt, wait=6):
|
|
347
|
+
"""滚动查找文本,双向(EXEC-005):上滑 N 次找 → 未中则下滑回落 N 次找。
|
|
348
|
+
单向滚动在连续断言场景必挂:上一断言滑到底后,本断言目标在上方,单向永远回不去。"""
|
|
349
|
+
import time as _t
|
|
350
|
+
w, h = d.get_display_size()
|
|
351
|
+
def _check():
|
|
352
|
+
try:
|
|
353
|
+
return d.find_component(BY.text(txt, MatchPattern.CONTAINS)) is not None
|
|
354
|
+
except Exception:
|
|
355
|
+
return False
|
|
356
|
+
for direction in (UiParam.UP, UiParam.DOWN):
|
|
357
|
+
for i in range(6):
|
|
358
|
+
if _check():
|
|
359
|
+
return True
|
|
360
|
+
d.swipe(direction, distance=40)
|
|
361
|
+
_t.sleep(1.0)
|
|
362
|
+
return text_exist(d, txt, wait)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def smart_check(d, cid, txt=None, wait=6):
|
|
366
|
+
"""key 优先断言;失败且有 txt 时降级文本断言(部分 HAR 模块 .id 运行时丢弃的边界)"""
|
|
367
|
+
import time as _t
|
|
368
|
+
deadline = _t.time() + wait
|
|
369
|
+
while _t.time() < deadline:
|
|
370
|
+
try:
|
|
371
|
+
c = d.find_component(BY.key(cid))
|
|
372
|
+
if c:
|
|
373
|
+
return True
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
_t.sleep(0.5)
|
|
377
|
+
if txt and txt.startswith("line"):
|
|
378
|
+
import re as _re
|
|
379
|
+
m = _re.match(r"line\d+:(\w+)", txt)
|
|
380
|
+
if m:
|
|
381
|
+
d.check_component_exist(BY.type(m.group(1)), wait_time=wait)
|
|
382
|
+
return True
|
|
383
|
+
if txt:
|
|
384
|
+
return text_exist(d, txt, wait)
|
|
385
|
+
raise AssertionError("component not found: " + cid)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def text_equals(d, txt, wait=6):
|
|
389
|
+
"""精确文本断言(EQUALS)——CONTAINS 会被前缀污染(如 ❄️Gallery 含 Gallery),
|
|
390
|
+
恢复/状态标记类断言必须用本原语。存在且精确相等返回 True,否则抛。"""
|
|
391
|
+
import time as _t
|
|
392
|
+
deadline = _t.time() + wait
|
|
393
|
+
while _t.time() < deadline:
|
|
394
|
+
try:
|
|
395
|
+
c = d.find_component(BY.text(txt))
|
|
396
|
+
if c:
|
|
397
|
+
return True
|
|
398
|
+
except Exception:
|
|
399
|
+
pass
|
|
400
|
+
_t.sleep(0.5)
|
|
401
|
+
raise AssertionError("text (exact) not found: " + txt)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def text_absent(d, txt, wait=3):
|
|
405
|
+
"""文本不存在断言(反向;等待短暂稳定后确认消失)"""
|
|
406
|
+
import time as _t
|
|
407
|
+
_t.sleep(1.0)
|
|
408
|
+
try:
|
|
409
|
+
c = d.find_component(BY.text(txt, MatchPattern.CONTAINS))
|
|
410
|
+
if c:
|
|
411
|
+
raise AssertionError("text should be absent but found: " + txt)
|
|
412
|
+
except AssertionError:
|
|
413
|
+
raise
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
return True
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def step_shot(d, label="step"):
|
|
420
|
+
"""分步截图:一个动作/断言一张页面图(run_direct 设 FORGE_SHOT_DIR/FORGE_CASE 时启用)。
|
|
421
|
+
文件名 <case>_<序号>_<label>.jpeg,供 test-report.html 按步回放页面。"""
|
|
422
|
+
import os as _os
|
|
423
|
+
base = _os.environ.get("FORGE_SHOT_DIR")
|
|
424
|
+
case = _os.environ.get("FORGE_CASE", "case")
|
|
425
|
+
if not base:
|
|
426
|
+
return
|
|
427
|
+
n = step_shot.n.get(case, 0) + 1
|
|
428
|
+
try:
|
|
429
|
+
d.screenshot(_os.path.join(base, "%s_%02d_%s.jpeg" % (case, n, label)))
|
|
430
|
+
step_shot.n[case] = n
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
step_shot.n = {}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def wait_for(d, by, timeout=10, interval=0.5):
|
|
439
|
+
"""轮询等待控件出现(首屏锚点等待用);返回组件或 None,不抛异常"""
|
|
440
|
+
import time as _t
|
|
441
|
+
end = _t.time() + timeout
|
|
442
|
+
while _t.time() < end:
|
|
443
|
+
try:
|
|
444
|
+
c = d.find_component(by)
|
|
445
|
+
if c:
|
|
446
|
+
return c
|
|
447
|
+
except Exception:
|
|
448
|
+
pass
|
|
449
|
+
_t.sleep(interval)
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def text_exist(d, txt, wait=6):
|
|
454
|
+
"""文本存在断言(BY.text CONTAINS)。
|
|
455
|
+
契约:存在返回 True;不存在【抛 AssertionError】——永不返回 False。
|
|
456
|
+
run_direct/analyze_results 依赖异常文本做 FAIL 判定与失败聚类,勿改为静默返回。"""
|
|
457
|
+
import time as _t
|
|
458
|
+
deadline = _t.time() + wait
|
|
459
|
+
while _t.time() < deadline:
|
|
460
|
+
try:
|
|
461
|
+
c = d.find_component(BY.text(txt, MatchPattern.CONTAINS))
|
|
462
|
+
if c:
|
|
463
|
+
return True
|
|
464
|
+
except Exception:
|
|
465
|
+
pass
|
|
466
|
+
_t.sleep(0.5)
|
|
467
|
+
raise AssertionError("text not found: " + txt)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def text_value_equals(d, cid, expected, wait=6):
|
|
471
|
+
"""值断言(EXEC-040 实证+反证):getText() 精确等于期望——锚定 key 控件动态内容。"""
|
|
472
|
+
import time as _t
|
|
473
|
+
deadline = _t.time() + wait
|
|
474
|
+
while _t.time() < deadline:
|
|
475
|
+
try:
|
|
476
|
+
c = d.find_component(BY.key(cid))
|
|
477
|
+
if c and c.getText() == expected:
|
|
478
|
+
return True
|
|
479
|
+
except Exception:
|
|
480
|
+
pass
|
|
481
|
+
_t.sleep(0.5)
|
|
482
|
+
got = None
|
|
483
|
+
try:
|
|
484
|
+
c = d.find_component(BY.key(cid))
|
|
485
|
+
got = c.getText() if c else '(not found)'
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
488
|
+
raise AssertionError(f"text value mismatch: expect {expected!r} got {got!r} (key={cid})")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def text_value_contains(d, cid, part, wait=6):
|
|
492
|
+
"""值包含断言:getText() 包含子串(动态文案前缀不定时用)。"""
|
|
493
|
+
import time as _t
|
|
494
|
+
deadline = _t.time() + wait
|
|
495
|
+
while _t.time() < deadline:
|
|
496
|
+
try:
|
|
497
|
+
c = d.find_component(BY.key(cid))
|
|
498
|
+
if c and part in (c.getText() or ''):
|
|
499
|
+
return True
|
|
500
|
+
except Exception:
|
|
501
|
+
pass
|
|
502
|
+
_t.sleep(0.5)
|
|
503
|
+
raise AssertionError(f"text value not containing {part!r} (key={cid})")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def count_equals(d, target, expected, wait=6):
|
|
507
|
+
"""计数断言(EXEC-040 实证):key 或 CONTAINS 文本匹配的组件数==期望。"""
|
|
508
|
+
import time as _t, re as _re
|
|
509
|
+
deadline = _t.time() + wait
|
|
510
|
+
last = -1
|
|
511
|
+
while _t.time() < deadline:
|
|
512
|
+
try:
|
|
513
|
+
if _re.match(r'^[a-z][a-z0-9_]+$', target):
|
|
514
|
+
comps = d.find_all_components(BY.key(target))
|
|
515
|
+
else:
|
|
516
|
+
comps = d.find_all_components(BY.text(target, MatchPattern.CONTAINS))
|
|
517
|
+
last = len(comps) if comps else 0
|
|
518
|
+
if last == expected:
|
|
519
|
+
return True
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
_t.sleep(0.5)
|
|
523
|
+
raise AssertionError(f"count mismatch: expect {expected} got {last} (target={target})")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def is_below(d, cid, ref_cid, wait=6):
|
|
527
|
+
"""几何断言(EXEC-040 实证):cid 的 top >= ref_cid 的 bottom(垂直次序)。"""
|
|
528
|
+
import time as _t
|
|
529
|
+
deadline = _t.time() + wait
|
|
530
|
+
while _t.time() < deadline:
|
|
531
|
+
try:
|
|
532
|
+
a = d.find_component(BY.key(cid)); b = d.find_component(BY.key(ref_cid))
|
|
533
|
+
if a and b and a.getBounds().top >= b.getBounds().bottom:
|
|
534
|
+
return True
|
|
535
|
+
except Exception:
|
|
536
|
+
pass
|
|
537
|
+
_t.sleep(0.5)
|
|
538
|
+
raise AssertionError(f"geometry: {cid} not below {ref_cid}")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def toast_exist(d, txt, wait=4, poll=0.5):
|
|
542
|
+
"""Toast 断言(v3.1):轮询 hilog 捕获 Toast 文本。需 FORGE_HDC/FORGE_DEVICE。"""
|
|
543
|
+
import os as _os, subprocess as _sp, time as _tt
|
|
544
|
+
hdc = _os.environ.get("FORGE_HDC", "hdc"); dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
545
|
+
deadline = _tt.time() + wait
|
|
546
|
+
while _tt.time() < deadline:
|
|
547
|
+
try:
|
|
548
|
+
r = _sp.run([hdc, "-t", dev, "shell", "hilog", "-x"],
|
|
549
|
+
capture_output=True, timeout=8, text=True, errors="ignore")
|
|
550
|
+
if txt in (r.stdout or ""):
|
|
551
|
+
return True
|
|
552
|
+
except Exception:
|
|
553
|
+
pass
|
|
554
|
+
_tt.sleep(poll)
|
|
555
|
+
raise AssertionError("toast not found in hilog: " + txt)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# ── 强断言原语(2026-09-29 吸收自 none 臂实证:无 skill 的 LLM 凭通用能力即达成 34% 强断言,
|
|
559
|
+
# B 线无理由低于此——Toast/系统事件/数据断言按下表选型,禁止只写 exist)──
|
|
560
|
+
def _hdc(args, timeout=20):
|
|
561
|
+
import subprocess as _sp, os as _os
|
|
562
|
+
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
563
|
+
cmd = [HDC_BIN, "-t", dev] + args
|
|
564
|
+
r = _sp.run(cmd, capture_output=True, timeout=timeout)
|
|
565
|
+
return (r.stdout + r.stderr).decode("utf-8", "ignore")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _dump_layout(timeout=20):
|
|
569
|
+
"""取当前全量布局 JSON(含系统对话框/浮层),返回原始文本(Round-2:确保本地 tmp 目录存在)"""
|
|
570
|
+
import json as _json, os as _os
|
|
571
|
+
_os.makedirs("./tmp", exist_ok=True)
|
|
572
|
+
_hdc(["shell", "uitest", "dumpLayout", "-p", "/data/local/tmp/forge_dump.json"], timeout=timeout)
|
|
573
|
+
_hdc(["file", "recv", "/data/local/tmp/forge_dump.json", "./tmp/forge_dump.json"], timeout=timeout)
|
|
574
|
+
try:
|
|
575
|
+
return open("./tmp/forge_dump.json", encoding="utf-8").read()
|
|
576
|
+
except Exception:
|
|
577
|
+
return ""
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def hilog_clear():
|
|
581
|
+
"""清 hilog 缓冲,防止上一用例残留关键字造成假 PASS(tag=RaiseToAnswer)"""
|
|
582
|
+
_hdc(["shell", "hilog", "-r"])
|
|
583
|
+
time.sleep(0.3)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
HILOG_TAG = 'JSAPP'
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def hilog_tail(tag=None, n=400):
|
|
590
|
+
tag = tag or HILOG_TAG
|
|
591
|
+
"""读取 tag 过滤的最近 n 行 hilog(诊断用)"""
|
|
592
|
+
import subprocess as _sp, os as _os
|
|
593
|
+
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
594
|
+
r = _sp.run([HDC_BIN, "-t", dev, "shell", "hilog", "-z", str(n), "-T", tag],
|
|
595
|
+
capture_output=True, timeout=15)
|
|
596
|
+
return r.stdout.decode("utf-8", "ignore")
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def toast_text_seen(d, txt, timeout=6):
|
|
600
|
+
"""Toast 文本断言(dump 版)。无 ID 例外:Toast 为瞬时浮层无稳定 ID,且 hypium find_component
|
|
601
|
+
实测看不到 Toast 子窗口,故以 uitest dumpLayout 原文轮询(首轮实测教训 Round-1)。"""
|
|
602
|
+
deadline = time.time() + timeout
|
|
603
|
+
while time.time() < deadline:
|
|
604
|
+
raw = _dump_layout()
|
|
605
|
+
if txt in raw:
|
|
606
|
+
return True
|
|
607
|
+
time.sleep(0.6)
|
|
608
|
+
raise AssertionError("toast not seen: " + txt)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def bm_clean():
|
|
612
|
+
"""清应用持久化数据(公开包装;import * 不导出 _ 前缀——Round-1 教训)"""
|
|
613
|
+
_hdc(["shell", "bm", "clean", "-n", PACKAGE, "-d"])
|
|
614
|
+
time.sleep(0.6)
|