@harmonyos-arkts/d2h 0.1.11 → 0.1.13
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/.claude-plugin/marketplace.json +15 -13
- package/.claude-plugin/plugin.json +13 -7
- package/AGENTS.md +5 -5
- package/README.md +212 -212
- package/agents/android-to-hmos-00-orchestrator.md +318 -318
- package/agents/d2h.md +157 -157
- package/dist/bin/install-opencode.js +30 -30
- package/docs/environment-check.md +86 -86
- package/install-opencode.sh +160 -160
- package/opencode/agents.json +16 -16
- package/opencode/vscode-d2h-package.json +7 -7
- package/opencode/vscode-d2h-plugin.mjs +29 -29
- package/package.json +54 -54
- package/schemas/android-source-manifest.schema.json +25 -25
- package/schemas/checkpoint-provenance.schema.json +67 -67
- package/schemas/final-acceptance.schema.json +29 -29
- package/schemas/managed-evidence-index.schema.json +15 -15
- package/schemas/managed-evidence.schema.json +114 -114
- package/schemas/migration-config.schema.json +51 -51
- package/schemas/migration-report-index.schema.json +39 -39
- package/schemas/migration-report-item.schema.json +51 -51
- package/schemas/migration-report-summary.schema.json +25 -25
- package/schemas/migration-status.schema.json +202 -202
- package/schemas/preflight.schema.json +60 -60
- package/schemas/source-order-audit.schema.json +16 -16
- package/schemas/source-provenance-event.schema.json +19 -19
- package/schemas/spec-app-shard.schema.json +45 -45
- package/schemas/spec-fact-corrections-shard.schema.json +13 -13
- package/schemas/spec-features-shard.schema.json +43 -43
- package/schemas/spec-index.schema.json +26 -26
- package/schemas/spec-interactions-shard.schema.json +40 -40
- package/schemas/spec-page.schema.json +98 -98
- package/schemas/spec-pages-index.schema.json +1 -1
- package/schemas/spec-unresolved-shard.schema.json +1 -1
- package/schemas/task-envelope.schema.json +109 -109
- package/schemas/task-plan.schema.json +341 -341
- package/scripts/check-environment.mjs +320 -320
- package/skills/android2hmos_resources_convert/SKILL.md +180 -180
- package/skills/android2hmos_resources_convert/references/image-conversion-rules.md +230 -230
- package/skills/android2hmos_resources_convert/references/svg-fix-patterns.md +175 -175
- package/skills/android2hmos_resources_convert/references/xml-drawable-to-svg-rules.md +513 -513
- package/skills/arkts-app-identity/SKILL.md +238 -238
- package/skills/arkts-i18n/SKILL.md +496 -496
- package/skills/arkts-i18n/evals/evals.json +84 -84
- package/skills/arkts-i18n/references/code-examples.md +302 -302
- package/skills/arkts-i18n/references/common-pitfalls.md +391 -391
- package/skills/arkts-i18n/references/dynamic-language-switch.md +604 -604
- package/skills/arkts-i18n/references/hardcoded-string-scanner.md +348 -348
- package/skills/arkts-i18n/references/language-codes.md +104 -104
- package/skills/arkts-i18n/references/resource-file-structure.md +775 -775
- package/skills/arkts-i18n/references/static-vs-dynamic.md +242 -242
- package/skills/arkts-i18n/references/v1-compat.md +244 -244
- package/skills/arkts-i18n/scripts/audit_i18n_completeness.sh +174 -174
- package/skills/arkts-icon-sizing/SKILL.md +211 -211
- package/skills/arkts-icon-sizing/scripts/icon_audit.py +131 -131
- package/skills/arkts-icon-sizing/scripts/icon_autofix.py +88 -88
- package/skills/arkts-icon-sizing/scripts/icon_dims.py +179 -179
- package/skills/arkts-icon-sizing/scripts/icon_fix.py +119 -119
- package/skills/arkts-mvvm-architecture/SKILL.md +613 -613
- package/skills/d2h-hypium-forge/SKILL.md +309 -309
- package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -140
- package/skills/d2h-hypium-forge/references/official-api.md +135 -135
- package/skills/d2h-hypium-forge/references/pitfalls.md +98 -98
- package/skills/d2h-hypium-forge/references/testability-template.md +71 -71
- package/skills/d2h-hypium-forge/scripts/_common_template.py +659 -659
- package/skills/d2h-hypium-forge/scripts/analyze_results.py +165 -165
- package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -107
- package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -169
- package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -325
- package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -114
- package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -80
- package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -60
- package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -249
- package/skills/d2h-hypium-forge/scripts/precondition.py +140 -140
- package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -232
- package/skills/d2h-hypium-forge/scripts/probe.py +197 -197
- package/skills/d2h-hypium-forge/scripts/run_direct.py +823 -823
- package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -121
- package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -255
- package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -66
- package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -84
- package/skills/d2h-page-develop/SKILL.md +112 -112
- package/skills/d2h-page-develop/agents/openai.yaml +7 -7
- package/skills/d2h-page-spec-generate/SKILL.md +206 -206
- package/skills/d2h-page-spec-generate/scripts/build_batch_report.py +227 -227
- package/skills/d2h-utg-ai-generate/SKILL.md +128 -128
- package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -76
- package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -34
- package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -96
- package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -61
- package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -89
- package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -314
- package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -123
- package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -280
- package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -524
- package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -489
- package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -69
- package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -13
- package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -2
- package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -369
- package/skills/d2h-utg-static-generate/SKILL.md +73 -73
- package/skills/d2h-utg-static-generate/references/discoveries.md +38 -38
- package/skills/d2h-utg-static-generate/scripts/generate.py +499 -499
- package/skills/d2h-utg-static-generate/tests/test_static.py +335 -335
- package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -60
- package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -131
- package/skills/harmonyos-migration-playbook/SKILL.md +83 -83
- package/skills/harmonyos-migration-playbook/agents/openai.yaml +7 -7
- package/skills/harmonyos-migration-playbook/references/arkts-compile.md +24 -24
- package/skills/harmonyos-migration-playbook/references/harmony-runtime.md +53 -53
- package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +45 -45
- package/skills/harmonyos-migration-playbook/references/protocol-e2e.md +23 -23
- package/skills/harmonyos-migration-playbook/references/ui-automation.md +53 -53
- package/skills/harmonyos-migration-playbook/references/windows-environment.md +38 -38
- package/skills/maintaining-migration-report/SKILL.md +144 -144
- package/skills/maintaining-migration-report/references/final-acceptance.md +48 -48
- package/skills/maintaining-migration-report/references/source-provenance.md +66 -66
- package/skills/managing-d2h-toolchain/SKILL.md +24 -24
- package/skills/managing-d2h-toolchain/environment-requirements.json +16 -16
- package/skills/managing-d2h-toolchain/scripts/manage-toolchain.mjs +98 -98
- package/skills/managing-d2h-toolchain/toolchain.json +16 -16
- package/skills/native-library-substitution/SKILL.md +384 -384
- package/skills/native-library-substitution/references/native-library-substitution.json +56905 -56905
- package/skills/native-library-substitution/references/native-library-substitution.md +163 -163
- package/skills/preparing-migration-workspace/SKILL.md +131 -131
- package/skills/reviewing-migration-process/SKILL.md +62 -62
|
@@ -1,659 +1,659 @@
|
|
|
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 os as _os
|
|
15
|
-
import time
|
|
16
|
-
from hypium import *
|
|
17
|
-
from hypium.model import MatchPattern, UiParam
|
|
18
|
-
|
|
19
|
-
try:
|
|
20
|
-
WAIT_SCALE = float(_os.environ.get('FORGE_WAIT_SCALE', '1') or 1)
|
|
21
|
-
except ValueError:
|
|
22
|
-
WAIT_SCALE = 1.0
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _w(sec):
|
|
26
|
-
"""等待时长缩放(env FORGE_WAIT_SCALE,默认 1 不变):调试迭代期设 0.5
|
|
27
|
-
可把失败路径的超时烧穿时间减半(Grit 实证 FAIL 用例 17-36s 多为等待超时);
|
|
28
|
-
正式回归/出报告必须保持 1,防止慢设备真问题被截短掩盖。"""
|
|
29
|
-
return max(0.5, sec * WAIT_SCALE)
|
|
30
|
-
|
|
31
|
-
PACKAGE = "me.hackerchick.raisetoanswer"
|
|
32
|
-
ABILITY = "EntryAbility"
|
|
33
|
-
ENTRY_PAGE = "pages/Index"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# ── Level 0 统一引导引擎(TD-008):权限/隐私/广告/引导弹窗 + tab 归位,一揽子消化 ──
|
|
37
|
-
# 关卡模型:每关 = 探测特征文案集合 → 处置(点击命中项)→ 循环消化到无特征。
|
|
38
|
-
# app 级配置 GATES(按需增删);bootstrap 幂等(主页锚可见即成功),超时输出页面快照供分诊。
|
|
39
|
-
|
|
40
|
-
GATES = [
|
|
41
|
-
{"name": "privacy", "texts": ["同意并继续", "同意", "知道了", "我知道了"]},
|
|
42
|
-
{"name": "permission", "texts": ["仅在使用中允许", "使用应用时允许", "允许"]},
|
|
43
|
-
{"name": "ad", "texts": ["跳过", "关闭广告", "不再显示", "以后再说", "暂不"]},
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
def _page_texts(d):
|
|
47
|
-
try:
|
|
48
|
-
return [(c.getText() or '') for c in (d.find_all_components(BY.type("Text")) or [])
|
|
49
|
-
if c.getText()]
|
|
50
|
-
except Exception:
|
|
51
|
-
return []
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _visible_clickables(d):
|
|
55
|
-
"""一次拉全 Text+Button 可见文案集合(弹窗消化轮询的单 RPC 扫描,
|
|
56
|
-
替代逐文案 find_component 的 12+ RPC/轮——冷启消化从 ~3s/轮 降到 ~0.6s/轮)。"""
|
|
57
|
-
out = []
|
|
58
|
-
try:
|
|
59
|
-
for by in (BY.type("Text"), BY.type("Button")):
|
|
60
|
-
for c in (d.find_all_components(by) or []):
|
|
61
|
-
t = c.getText() or ""
|
|
62
|
-
if t:
|
|
63
|
-
out.append(t)
|
|
64
|
-
except Exception:
|
|
65
|
-
pass
|
|
66
|
-
return out
|
|
67
|
-
|
|
68
|
-
def bootstrap(d, gates=None, home_tab="", anchor_key="", total=60, log=print):
|
|
69
|
-
"""冷启到可用基座。流程:轮询消化全部弹窗关卡(一轮多点,直到无特征)→
|
|
70
|
-
tab 归位(点目标 tab + 验证锚)→ 主页锚可见返回 True。
|
|
71
|
-
anchor_key 是"基座就绪"的唯一判据(app 级配置,如目标 tab 的内容控件 id)。"""
|
|
72
|
-
gates = gates if gates is not None else GATES
|
|
73
|
-
all_texts = [t for g in gates for t in g["texts"]]
|
|
74
|
-
t0 = time.time()
|
|
75
|
-
dismissed = []
|
|
76
|
-
while time.time() - t0 < total:
|
|
77
|
-
# 1) 主页锚已可见 → 提前成功(幂等)
|
|
78
|
-
if anchor_key:
|
|
79
|
-
try:
|
|
80
|
-
if d.find_component(BY.key(anchor_key)) is not None:
|
|
81
|
-
if dismissed:
|
|
82
|
-
log(f" [L0] 弹窗消化: {dismissed}")
|
|
83
|
-
return True
|
|
84
|
-
except Exception:
|
|
85
|
-
pass
|
|
86
|
-
# 2) 消化所有弹窗关卡:一轮拉全可见文案(单 RPC 扫描),本地匹配命中再点击
|
|
87
|
-
hit_any = False
|
|
88
|
-
vis = set(_visible_clickables(d))
|
|
89
|
-
for t in all_texts:
|
|
90
|
-
if t == (home_tab or "") or t not in vis:
|
|
91
|
-
continue
|
|
92
|
-
try:
|
|
93
|
-
d.touch(BY.text(t))
|
|
94
|
-
dismissed.append(t)
|
|
95
|
-
hit_any = True
|
|
96
|
-
time.sleep(1.2)
|
|
97
|
-
break # 点一个后重新扫(弹窗可能叠加/消失后露下一个)
|
|
98
|
-
except Exception:
|
|
99
|
-
pass
|
|
100
|
-
if hit_any:
|
|
101
|
-
continue
|
|
102
|
-
pts = _page_texts(d)
|
|
103
|
-
# 3) 无弹窗特征:tab 归位(点目标 tab,锚验证)
|
|
104
|
-
if home_tab and home_tab in pts:
|
|
105
|
-
if anchor_key:
|
|
106
|
-
try:
|
|
107
|
-
if d.find_component(BY.key(anchor_key)) is not None:
|
|
108
|
-
return True
|
|
109
|
-
except Exception:
|
|
110
|
-
pass
|
|
111
|
-
try:
|
|
112
|
-
d.touch(BY.text(home_tab))
|
|
113
|
-
time.sleep(2.0)
|
|
114
|
-
except Exception:
|
|
115
|
-
pass
|
|
116
|
-
time.sleep(0.9)
|
|
117
|
-
# 超时:输出页面快照供分诊
|
|
118
|
-
log(f" [L0] ✗ bootstrap 超时 {total}s;页面文本快照: {_page_texts(d)[:15]}")
|
|
119
|
-
return False
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
# ── Level 0 前置三件套(TD-007:冷启自适应,用例不得自带)────────────────
|
|
123
|
-
# ① 首启弹窗 dismiss:多文案轮询(隐私/权限/引导),幂等无弹窗秒过
|
|
124
|
-
FIRST_RUN_DISMISS_TEXTS = ["同意并继续", "同意", "允许", "仅在使用中允许", "我知道了", "知道了", "暂不", "以后再说"]
|
|
125
|
-
|
|
126
|
-
def dismiss_first_run(d, rounds=3, gap=1.2):
|
|
127
|
-
for _ in range(rounds):
|
|
128
|
-
hit = False
|
|
129
|
-
for t in FIRST_RUN_DISMISS_TEXTS:
|
|
130
|
-
try:
|
|
131
|
-
c = d.find_component(BY.text(t))
|
|
132
|
-
if c is not None:
|
|
133
|
-
d.touch(BY.text(t)); time.sleep(gap); hit = True; break
|
|
134
|
-
except Exception:
|
|
135
|
-
pass
|
|
136
|
-
if not hit:
|
|
137
|
-
return
|
|
138
|
-
|
|
139
|
-
# ② 首页 tab 归位:应用默认落在非目标 tab(如广场)时点回目标 tab(HOME_TAB 应用级配置)
|
|
140
|
-
def ensure_home_tab(d, tab_text, timeout=10):
|
|
141
|
-
t0 = time.time()
|
|
142
|
-
while time.time() - t0 < timeout:
|
|
143
|
-
try:
|
|
144
|
-
if d.find_component(BY.text(tab_text)) is not None:
|
|
145
|
-
d.touch(BY.text(tab_text)); time.sleep(1.5); return True
|
|
146
|
-
except Exception:
|
|
147
|
-
pass
|
|
148
|
-
time.sleep(0.8)
|
|
149
|
-
return False
|
|
150
|
-
|
|
151
|
-
# ③ 冷启三件套:launch 内自动执行——弹窗 dismiss + tab 归位(HOME_TAB 非空时)
|
|
152
|
-
HOME_TAB = "" # 应用级:主功能 tab 文案(如 "玩安卓");空串跳过归位
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def launch(d, want="", reset_data=False, force=False):
|
|
156
|
-
"""v3-①②(EXEC-034):want=启动参数('k=v;k2=v2' → hdc aa start --ps 透传,启动参数门控应用免人工补丁);
|
|
157
|
-
reset_data=True → bm clean -n 清应用数据(用例级状态重置,防跨用例泄漏);
|
|
158
|
-
force=True → 强制冷启(断言冷启行为的用例用)。
|
|
159
|
-
热启动(run_direct 组内非首条置 FORGE_WARM_LAUNCH=1):HOME_ANCHOR 已在位即软复用
|
|
160
|
-
当前会话跳过 stop/start/bootstrap(省冷启 8-12s/例);锚不可见自动回退完整冷启。"""
|
|
161
|
-
import os as _os, subprocess as _sp
|
|
162
|
-
if not want and not reset_data and not force and _os.environ.get("FORGE_WARM_LAUNCH") == "1":
|
|
163
|
-
anchor = globals().get("HOME_ANCHOR", "")
|
|
164
|
-
if anchor:
|
|
165
|
-
try:
|
|
166
|
-
if d.find_component(BY.key(anchor)) is not None:
|
|
167
|
-
return # 基座已就位:软复用(上一用例恰好停在本页)
|
|
168
|
-
except Exception:
|
|
169
|
-
pass
|
|
170
|
-
d.stop_app(PACKAGE)
|
|
171
|
-
time.sleep(0.8)
|
|
172
|
-
if reset_data:
|
|
173
|
-
hdc = _os.environ.get("FORGE_HDC", "hdc")
|
|
174
|
-
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
175
|
-
try:
|
|
176
|
-
# DEVIATION-3: bm clean 不带 -d 时仅清 cache(-c),持久化不清,用例间状态泄漏
|
|
177
|
-
# (TC02 关 answer 残留致 TC03/08/09 连锁失败);本机 bm 无 -b 选项,-d 清数据
|
|
178
|
-
_sp.run([hdc, "-t", dev, "shell", "bm", "clean", "-n", PACKAGE, "-d"],
|
|
179
|
-
capture_output=True, timeout=15)
|
|
180
|
-
time.sleep(0.5)
|
|
181
|
-
except Exception as e:
|
|
182
|
-
print(" [reset] bm clean 失败:", e)
|
|
183
|
-
if want:
|
|
184
|
-
hdc = _os.environ.get("FORGE_HDC", "hdc")
|
|
185
|
-
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
186
|
-
args = ["aa", "start", "-b", PACKAGE, "-a", ABILITY]
|
|
187
|
-
for kv in want.split(";"):
|
|
188
|
-
if "=" in kv:
|
|
189
|
-
k, v = kv.split("=", 1)
|
|
190
|
-
# DEVIATION-1: aa start 的 --ps 只接受空格分隔 key value,等号形式报
|
|
191
|
-
# "invalid number of parameters for option --ps" 导致带参启动全部失败
|
|
192
|
-
args += ["--ps", k, v]
|
|
193
|
-
# EXEC-038 修复:带参启动 3 轮重试 + 实测探活(FORGE_WANT_PROBE=key,可寻才算活)
|
|
194
|
-
probe = _os.environ.get("FORGE_WANT_PROBE", "")
|
|
195
|
-
for attempt in range(3):
|
|
196
|
-
try:
|
|
197
|
-
_sp.run([hdc, "-t", dev, "shell", "aa", "force-stop", PACKAGE], capture_output=True, timeout=10)
|
|
198
|
-
time.sleep(0.8)
|
|
199
|
-
_sp.run([hdc, "-t", dev, "shell"] + args, capture_output=True, timeout=15)
|
|
200
|
-
time.sleep(3.0)
|
|
201
|
-
if probe:
|
|
202
|
-
c = None
|
|
203
|
-
try:
|
|
204
|
-
c = d.find_component(BY.key(probe))
|
|
205
|
-
except Exception:
|
|
206
|
-
pass
|
|
207
|
-
if c is None:
|
|
208
|
-
print(f" [want] 第{attempt+1}轮探活失败(key={probe}),重试")
|
|
209
|
-
time.sleep(1.5)
|
|
210
|
-
continue
|
|
211
|
-
_dismiss_first_run(d)
|
|
212
|
-
return
|
|
213
|
-
except Exception as e:
|
|
214
|
-
print(" [want] 带参启动异常,重试:", e)
|
|
215
|
-
time.sleep(1.5)
|
|
216
|
-
print(" [want] 3 轮未探活成功,继续交由断言暴露")
|
|
217
|
-
d.start_app(PACKAGE, ABILITY)
|
|
218
|
-
ok = bootstrap(d, home_tab=HOME_TAB, anchor_key=HOME_ANCHOR)
|
|
219
|
-
if not ok:
|
|
220
|
-
raise AssertionError("L0 bootstrap failed(弹窗/tab 未消化,见日志快照)")
|
|
221
|
-
time.sleep(1.5) # bootstrap 锚已验证就位,1.5s 落座余量(原 2.5s)
|
|
222
|
-
_dismiss_first_run(d)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def _dismiss_first_run(d):
|
|
226
|
-
"""首启弹窗一键处置(权限/隐私协议)——单 RPC 拉全文案本地匹配"""
|
|
227
|
-
vis = set(_visible_clickables(d))
|
|
228
|
-
for txt in ["同意并继续", "允许", "我知道了", "暂不", "以后再说", "同意"]:
|
|
229
|
-
if txt not in vis:
|
|
230
|
-
continue
|
|
231
|
-
try:
|
|
232
|
-
c = d.find_component(BY.text(txt))
|
|
233
|
-
if c:
|
|
234
|
-
c.click()
|
|
235
|
-
time.sleep(0.8)
|
|
236
|
-
vis = set(_visible_clickables(d)) # 点掉一层后重扫(可能叠弹窗)
|
|
237
|
-
except Exception:
|
|
238
|
-
pass
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def stop(d):
|
|
242
|
-
d.stop_app(PACKAGE)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def nav_to(d, page):
|
|
246
|
-
"""导航到目标页:入口页直接用;非入口页用返回键回根后再走用例自带链(由用例 steps 驱动)"""
|
|
247
|
-
if page == ENTRY_PAGE:
|
|
248
|
-
return
|
|
249
|
-
# 返回根页(最多 5 次)
|
|
250
|
-
for _ in range(5):
|
|
251
|
-
try:
|
|
252
|
-
if d.find_component(BY.key("__never__")) is None:
|
|
253
|
-
pass
|
|
254
|
-
except Exception:
|
|
255
|
-
pass
|
|
256
|
-
d.press_back()
|
|
257
|
-
time.sleep(0.4)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
TAB_LABELS = ["首页", "视频", "我的"]
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def click_tab(d, label):
|
|
264
|
-
"""底部 Tab 切换:定位文本→bounds 中心 touch(比例坐标兜底)"""
|
|
265
|
-
import time as _t
|
|
266
|
-
for _ in range(8):
|
|
267
|
-
try:
|
|
268
|
-
comps = d.find_all_components(BY.text(label))
|
|
269
|
-
if comps:
|
|
270
|
-
c = comps[-1] # 底部 tab 是同名文本中最靠下的
|
|
271
|
-
b = c.getBounds()
|
|
272
|
-
cx = (b.left + b.right) // 2
|
|
273
|
-
cy = (b.top + b.bottom) // 2
|
|
274
|
-
d.touch((int(cx), int(cy)))
|
|
275
|
-
_t.sleep(1.2)
|
|
276
|
-
return
|
|
277
|
-
except Exception:
|
|
278
|
-
pass
|
|
279
|
-
_t.sleep(0.25)
|
|
280
|
-
idx = TAB_LABELS.index(label) if label in TAB_LABELS else 2
|
|
281
|
-
w, h = d.get_display_size()
|
|
282
|
-
d.touch((int(w * (idx + 0.5) / len(TAB_LABELS)), int(h * 0.945)))
|
|
283
|
-
time.sleep(1.2)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def click_text(d, txt, wait=6):
|
|
287
|
-
"""按可见文本点击(tab 名/中文按钮等无 ID 控件)。
|
|
288
|
-
先试 BY.key——无下划线的单词 id(如 fab)无法与文本区分(_is_id_like 局限),此处兜底。"""
|
|
289
|
-
import time as _t
|
|
290
|
-
try:
|
|
291
|
-
c = d.find_component(BY.key(txt))
|
|
292
|
-
if c:
|
|
293
|
-
c.click()
|
|
294
|
-
_t.sleep(1.0)
|
|
295
|
-
return
|
|
296
|
-
except Exception:
|
|
297
|
-
pass
|
|
298
|
-
deadline = _t.time() + _w(wait)
|
|
299
|
-
while _t.time() < deadline:
|
|
300
|
-
try:
|
|
301
|
-
c = d.find_component(BY.text(txt, MatchPattern.CONTAINS))
|
|
302
|
-
if c:
|
|
303
|
-
c.click()
|
|
304
|
-
_t.sleep(1.0)
|
|
305
|
-
return
|
|
306
|
-
except Exception:
|
|
307
|
-
pass
|
|
308
|
-
_t.sleep(0.25)
|
|
309
|
-
raise AssertionError("text not clickable: " + txt)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
def click_type_first(d, ctype, wait=6):
|
|
313
|
-
"""点击指定类型的第一个组件(lineN:Type 引用的兜底点击)"""
|
|
314
|
-
import time as _t
|
|
315
|
-
deadline = _t.time() + _w(wait)
|
|
316
|
-
while _t.time() < deadline:
|
|
317
|
-
try:
|
|
318
|
-
comps = d.find_all_components(BY.type(ctype))
|
|
319
|
-
if comps:
|
|
320
|
-
b = comps[0].getBounds()
|
|
321
|
-
d.touch(((b.left + b.right) // 2, (b.top + b.bottom) // 2))
|
|
322
|
-
_t.sleep(1.2)
|
|
323
|
-
return
|
|
324
|
-
except Exception:
|
|
325
|
-
pass
|
|
326
|
-
_t.sleep(0.25)
|
|
327
|
-
raise AssertionError("type not clickable: " + ctype)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def id_click(d, cid, wait=6):
|
|
331
|
-
comp = _find(d, cid, wait)
|
|
332
|
-
comp.click()
|
|
333
|
-
time.sleep(1.0)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def id_input(d, cid, text, wait=6):
|
|
337
|
-
comp = _find(d, cid, wait)
|
|
338
|
-
d.input_text(comp, text)
|
|
339
|
-
time.sleep(0.6)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def _find(d, cid, wait=6):
|
|
343
|
-
import time as _t
|
|
344
|
-
deadline = _t.time() + _w(wait)
|
|
345
|
-
while _t.time() < deadline:
|
|
346
|
-
try:
|
|
347
|
-
c = d.find_component(BY.key(cid))
|
|
348
|
-
if c:
|
|
349
|
-
return c
|
|
350
|
-
except Exception:
|
|
351
|
-
pass
|
|
352
|
-
try:
|
|
353
|
-
c = d.find_component(BY.id(cid))
|
|
354
|
-
if c:
|
|
355
|
-
return c
|
|
356
|
-
except Exception:
|
|
357
|
-
pass
|
|
358
|
-
_t.sleep(0.25)
|
|
359
|
-
raise AssertionError("component not found: " + cid)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def smart_click(d, cid, txt=None, wait=6):
|
|
363
|
-
"""key 优先点击;失败且有 txt 时文本点击(mine 类 HAR 模块 ID 丢失边界)"""
|
|
364
|
-
import time as _t
|
|
365
|
-
deadline = _t.time() + _w(wait)
|
|
366
|
-
while _t.time() < deadline:
|
|
367
|
-
try:
|
|
368
|
-
c = d.find_component(BY.key(cid))
|
|
369
|
-
if c:
|
|
370
|
-
c.click()
|
|
371
|
-
_t.sleep(1.0)
|
|
372
|
-
return
|
|
373
|
-
except Exception:
|
|
374
|
-
pass
|
|
375
|
-
_t.sleep(0.25)
|
|
376
|
-
if txt and txt.startswith("line"):
|
|
377
|
-
import re as _re
|
|
378
|
-
m = _re.match(r"line\d+:(\w+)", txt)
|
|
379
|
-
if m:
|
|
380
|
-
click_type_first(d, m.group(1), wait)
|
|
381
|
-
return
|
|
382
|
-
if txt:
|
|
383
|
-
click_text(d, txt, wait)
|
|
384
|
-
return
|
|
385
|
-
raise AssertionError("component not found: " + cid)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
def scroll_text_exist(d, txt, wait=6):
|
|
389
|
-
"""滚动查找文本,双向(EXEC-005):上滑 N 次找 → 未中则下滑回落 N 次找。
|
|
390
|
-
单向滚动在连续断言场景必挂:上一断言滑到底后,本断言目标在上方,单向永远回不去。"""
|
|
391
|
-
import time as _t
|
|
392
|
-
w, h = d.get_display_size()
|
|
393
|
-
def _check():
|
|
394
|
-
try:
|
|
395
|
-
return d.find_component(BY.text(txt, MatchPattern.CONTAINS)) is not None
|
|
396
|
-
except Exception:
|
|
397
|
-
return False
|
|
398
|
-
for direction in (UiParam.UP, UiParam.DOWN):
|
|
399
|
-
for i in range(6):
|
|
400
|
-
if _check():
|
|
401
|
-
return True
|
|
402
|
-
d.swipe(direction, distance=40)
|
|
403
|
-
_t.sleep(1.0)
|
|
404
|
-
return text_exist(d, txt, wait)
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
def smart_check(d, cid, txt=None, wait=6):
|
|
408
|
-
"""key 优先断言;失败且有 txt 时降级文本断言(部分 HAR 模块 .id 运行时丢弃的边界)"""
|
|
409
|
-
import time as _t
|
|
410
|
-
deadline = _t.time() + _w(wait)
|
|
411
|
-
while _t.time() < deadline:
|
|
412
|
-
try:
|
|
413
|
-
c = d.find_component(BY.key(cid))
|
|
414
|
-
if c:
|
|
415
|
-
return True
|
|
416
|
-
except Exception:
|
|
417
|
-
pass
|
|
418
|
-
_t.sleep(0.25)
|
|
419
|
-
if txt and txt.startswith("line"):
|
|
420
|
-
import re as _re
|
|
421
|
-
m = _re.match(r"line\d+:(\w+)", txt)
|
|
422
|
-
if m:
|
|
423
|
-
d.check_component_exist(BY.type(m.group(1)), wait_time=wait)
|
|
424
|
-
return True
|
|
425
|
-
if txt:
|
|
426
|
-
return text_exist(d, txt, wait)
|
|
427
|
-
raise AssertionError("component not found: " + cid)
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
def text_equals(d, txt, wait=6):
|
|
431
|
-
"""精确文本断言(EQUALS)——CONTAINS 会被前缀污染(如 ❄️Gallery 含 Gallery),
|
|
432
|
-
恢复/状态标记类断言必须用本原语。存在且精确相等返回 True,否则抛。"""
|
|
433
|
-
import time as _t
|
|
434
|
-
deadline = _t.time() + _w(wait)
|
|
435
|
-
while _t.time() < deadline:
|
|
436
|
-
try:
|
|
437
|
-
c = d.find_component(BY.text(txt))
|
|
438
|
-
if c:
|
|
439
|
-
return True
|
|
440
|
-
except Exception:
|
|
441
|
-
pass
|
|
442
|
-
_t.sleep(0.25)
|
|
443
|
-
raise AssertionError("text (exact) not found: " + txt)
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
def text_absent(d, txt, wait=3):
|
|
447
|
-
"""文本不存在断言(反向;等待短暂稳定后确认消失)"""
|
|
448
|
-
import time as _t
|
|
449
|
-
_t.sleep(1.0)
|
|
450
|
-
try:
|
|
451
|
-
c = d.find_component(BY.text(txt, MatchPattern.CONTAINS))
|
|
452
|
-
if c:
|
|
453
|
-
raise AssertionError("text should be absent but found: " + txt)
|
|
454
|
-
except AssertionError:
|
|
455
|
-
raise
|
|
456
|
-
except Exception:
|
|
457
|
-
pass
|
|
458
|
-
return True
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
def step_shot(d, label="step"):
|
|
462
|
-
"""分步截图:一个动作/断言一张页面图(run_direct 设 FORGE_SHOT_DIR/FORGE_CASE 时启用)。
|
|
463
|
-
文件名 <case>_<序号>_<label>.jpeg,供 test-report.html 按步回放页面。
|
|
464
|
-
FORGE_SHOT_MODE=off 时跳过;fail 模式下 PASS 用例的过程图由 run_direct 统一清理。"""
|
|
465
|
-
import os as _os
|
|
466
|
-
if _os.environ.get("FORGE_SHOT_MODE") == "off":
|
|
467
|
-
return
|
|
468
|
-
base = _os.environ.get("FORGE_SHOT_DIR")
|
|
469
|
-
case = _os.environ.get("FORGE_CASE", "case")
|
|
470
|
-
if not base:
|
|
471
|
-
return
|
|
472
|
-
n = step_shot.n.get(case, 0) + 1
|
|
473
|
-
try:
|
|
474
|
-
d.screenshot(_os.path.join(base, "%s_%02d_%s.jpeg" % (case, n, label)))
|
|
475
|
-
step_shot.n[case] = n
|
|
476
|
-
except Exception:
|
|
477
|
-
pass
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
step_shot.n = {}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
def wait_for(d, by, timeout=10, interval=0.5):
|
|
484
|
-
"""轮询等待控件出现(首屏锚点等待用);返回组件或 None,不抛异常"""
|
|
485
|
-
import time as _t
|
|
486
|
-
end = _t.time() + _w(timeout)
|
|
487
|
-
while _t.time() < end:
|
|
488
|
-
try:
|
|
489
|
-
c = d.find_component(by)
|
|
490
|
-
if c:
|
|
491
|
-
return c
|
|
492
|
-
except Exception:
|
|
493
|
-
pass
|
|
494
|
-
_t.sleep(interval)
|
|
495
|
-
return None
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
def text_exist(d, txt, wait=6):
|
|
499
|
-
"""文本存在断言(BY.text CONTAINS)。
|
|
500
|
-
契约:存在返回 True;不存在【抛 AssertionError】——永不返回 False。
|
|
501
|
-
run_direct/analyze_results 依赖异常文本做 FAIL 判定与失败聚类,勿改为静默返回。"""
|
|
502
|
-
import time as _t
|
|
503
|
-
deadline = _t.time() + _w(wait)
|
|
504
|
-
while _t.time() < deadline:
|
|
505
|
-
try:
|
|
506
|
-
c = d.find_component(BY.text(txt, MatchPattern.CONTAINS))
|
|
507
|
-
if c:
|
|
508
|
-
return True
|
|
509
|
-
except Exception:
|
|
510
|
-
pass
|
|
511
|
-
_t.sleep(0.25)
|
|
512
|
-
raise AssertionError("text not found: " + txt)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
def text_value_equals(d, cid, expected, wait=6):
|
|
516
|
-
"""值断言(EXEC-040 实证+反证):getText() 精确等于期望——锚定 key 控件动态内容。"""
|
|
517
|
-
import time as _t
|
|
518
|
-
deadline = _t.time() + _w(wait)
|
|
519
|
-
while _t.time() < deadline:
|
|
520
|
-
try:
|
|
521
|
-
c = d.find_component(BY.key(cid))
|
|
522
|
-
if c and c.getText() == expected:
|
|
523
|
-
return True
|
|
524
|
-
except Exception:
|
|
525
|
-
pass
|
|
526
|
-
_t.sleep(0.25)
|
|
527
|
-
got = None
|
|
528
|
-
try:
|
|
529
|
-
c = d.find_component(BY.key(cid))
|
|
530
|
-
got = c.getText() if c else '(not found)'
|
|
531
|
-
except Exception:
|
|
532
|
-
pass
|
|
533
|
-
raise AssertionError(f"text value mismatch: expect {expected!r} got {got!r} (key={cid})")
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
def text_value_contains(d, cid, part, wait=6):
|
|
537
|
-
"""值包含断言:getText() 包含子串(动态文案前缀不定时用)。"""
|
|
538
|
-
import time as _t
|
|
539
|
-
deadline = _t.time() + _w(wait)
|
|
540
|
-
while _t.time() < deadline:
|
|
541
|
-
try:
|
|
542
|
-
c = d.find_component(BY.key(cid))
|
|
543
|
-
if c and part in (c.getText() or ''):
|
|
544
|
-
return True
|
|
545
|
-
except Exception:
|
|
546
|
-
pass
|
|
547
|
-
_t.sleep(0.25)
|
|
548
|
-
raise AssertionError(f"text value not containing {part!r} (key={cid})")
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
def count_equals(d, target, expected, wait=6):
|
|
552
|
-
"""计数断言(EXEC-040 实证):key 或 CONTAINS 文本匹配的组件数==期望。"""
|
|
553
|
-
import time as _t, re as _re
|
|
554
|
-
deadline = _t.time() + _w(wait)
|
|
555
|
-
last = -1
|
|
556
|
-
while _t.time() < deadline:
|
|
557
|
-
try:
|
|
558
|
-
if _re.match(r'^[a-z][a-z0-9_]+$', target):
|
|
559
|
-
comps = d.find_all_components(BY.key(target))
|
|
560
|
-
else:
|
|
561
|
-
comps = d.find_all_components(BY.text(target, MatchPattern.CONTAINS))
|
|
562
|
-
last = len(comps) if comps else 0
|
|
563
|
-
if last == expected:
|
|
564
|
-
return True
|
|
565
|
-
except Exception:
|
|
566
|
-
pass
|
|
567
|
-
_t.sleep(0.25)
|
|
568
|
-
raise AssertionError(f"count mismatch: expect {expected} got {last} (target={target})")
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
def is_below(d, cid, ref_cid, wait=6):
|
|
572
|
-
"""几何断言(EXEC-040 实证):cid 的 top >= ref_cid 的 bottom(垂直次序)。"""
|
|
573
|
-
import time as _t
|
|
574
|
-
deadline = _t.time() + _w(wait)
|
|
575
|
-
while _t.time() < deadline:
|
|
576
|
-
try:
|
|
577
|
-
a = d.find_component(BY.key(cid)); b = d.find_component(BY.key(ref_cid))
|
|
578
|
-
if a and b and a.getBounds().top >= b.getBounds().bottom:
|
|
579
|
-
return True
|
|
580
|
-
except Exception:
|
|
581
|
-
pass
|
|
582
|
-
_t.sleep(0.25)
|
|
583
|
-
raise AssertionError(f"geometry: {cid} not below {ref_cid}")
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
def toast_exist(d, txt, wait=4, poll=0.5):
|
|
587
|
-
"""Toast 断言(v3.1):轮询 hilog 捕获 Toast 文本。需 FORGE_HDC/FORGE_DEVICE。"""
|
|
588
|
-
import os as _os, subprocess as _sp, time as _tt
|
|
589
|
-
hdc = _os.environ.get("FORGE_HDC", "hdc"); dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
590
|
-
deadline = _tt.time() + _w(wait)
|
|
591
|
-
while _tt.time() < deadline:
|
|
592
|
-
try:
|
|
593
|
-
r = _sp.run([hdc, "-t", dev, "shell", "hilog", "-x"],
|
|
594
|
-
capture_output=True, timeout=8, text=True, errors="ignore")
|
|
595
|
-
if txt in (r.stdout or ""):
|
|
596
|
-
return True
|
|
597
|
-
except Exception:
|
|
598
|
-
pass
|
|
599
|
-
_tt.sleep(poll)
|
|
600
|
-
raise AssertionError("toast not found in hilog: " + txt)
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
# ── 强断言原语(2026-09-29 吸收自 none 臂实证:无 skill 的 LLM 凭通用能力即达成 34% 强断言,
|
|
604
|
-
# B 线无理由低于此——Toast/系统事件/数据断言按下表选型,禁止只写 exist)──
|
|
605
|
-
def _hdc(args, timeout=20):
|
|
606
|
-
import subprocess as _sp, os as _os
|
|
607
|
-
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
608
|
-
cmd = [HDC_BIN, "-t", dev] + args
|
|
609
|
-
r = _sp.run(cmd, capture_output=True, timeout=timeout)
|
|
610
|
-
return (r.stdout + r.stderr).decode("utf-8", "ignore")
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
def _dump_layout(timeout=20):
|
|
614
|
-
"""取当前全量布局 JSON(含系统对话框/浮层),返回原始文本(Round-2:确保本地 tmp 目录存在)"""
|
|
615
|
-
import json as _json, os as _os
|
|
616
|
-
_os.makedirs("./tmp", exist_ok=True)
|
|
617
|
-
_hdc(["shell", "uitest", "dumpLayout", "-p", "/data/local/tmp/forge_dump.json"], timeout=timeout)
|
|
618
|
-
_hdc(["file", "recv", "/data/local/tmp/forge_dump.json", "./tmp/forge_dump.json"], timeout=timeout)
|
|
619
|
-
try:
|
|
620
|
-
return open("./tmp/forge_dump.json", encoding="utf-8").read()
|
|
621
|
-
except Exception:
|
|
622
|
-
return ""
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
def hilog_clear():
|
|
626
|
-
"""清 hilog 缓冲,防止上一用例残留关键字造成假 PASS(tag=RaiseToAnswer)"""
|
|
627
|
-
_hdc(["shell", "hilog", "-r"])
|
|
628
|
-
time.sleep(0.3)
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
HILOG_TAG = 'JSAPP'
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
def hilog_tail(tag=None, n=400):
|
|
635
|
-
tag = tag or HILOG_TAG
|
|
636
|
-
"""读取 tag 过滤的最近 n 行 hilog(诊断用)"""
|
|
637
|
-
import subprocess as _sp, os as _os
|
|
638
|
-
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
639
|
-
r = _sp.run([HDC_BIN, "-t", dev, "shell", "hilog", "-z", str(n), "-T", tag],
|
|
640
|
-
capture_output=True, timeout=15)
|
|
641
|
-
return r.stdout.decode("utf-8", "ignore")
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
def toast_text_seen(d, txt, timeout=6):
|
|
645
|
-
"""Toast 文本断言(dump 版)。无 ID 例外:Toast 为瞬时浮层无稳定 ID,且 hypium find_component
|
|
646
|
-
实测看不到 Toast 子窗口,故以 uitest dumpLayout 原文轮询(首轮实测教训 Round-1)。"""
|
|
647
|
-
deadline = time.time() + _w(timeout)
|
|
648
|
-
while time.time() < deadline:
|
|
649
|
-
raw = _dump_layout()
|
|
650
|
-
if txt in raw:
|
|
651
|
-
return True
|
|
652
|
-
time.sleep(0.6)
|
|
653
|
-
raise AssertionError("toast not seen: " + txt)
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
def bm_clean():
|
|
657
|
-
"""清应用持久化数据(公开包装;import * 不导出 _ 前缀——Round-1 教训)"""
|
|
658
|
-
_hdc(["shell", "bm", "clean", "-n", PACKAGE, "-d"])
|
|
659
|
-
time.sleep(0.6)
|
|
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 os as _os
|
|
15
|
+
import time
|
|
16
|
+
from hypium import *
|
|
17
|
+
from hypium.model import MatchPattern, UiParam
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
WAIT_SCALE = float(_os.environ.get('FORGE_WAIT_SCALE', '1') or 1)
|
|
21
|
+
except ValueError:
|
|
22
|
+
WAIT_SCALE = 1.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _w(sec):
|
|
26
|
+
"""等待时长缩放(env FORGE_WAIT_SCALE,默认 1 不变):调试迭代期设 0.5
|
|
27
|
+
可把失败路径的超时烧穿时间减半(Grit 实证 FAIL 用例 17-36s 多为等待超时);
|
|
28
|
+
正式回归/出报告必须保持 1,防止慢设备真问题被截短掩盖。"""
|
|
29
|
+
return max(0.5, sec * WAIT_SCALE)
|
|
30
|
+
|
|
31
|
+
PACKAGE = "me.hackerchick.raisetoanswer"
|
|
32
|
+
ABILITY = "EntryAbility"
|
|
33
|
+
ENTRY_PAGE = "pages/Index"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Level 0 统一引导引擎(TD-008):权限/隐私/广告/引导弹窗 + tab 归位,一揽子消化 ──
|
|
37
|
+
# 关卡模型:每关 = 探测特征文案集合 → 处置(点击命中项)→ 循环消化到无特征。
|
|
38
|
+
# app 级配置 GATES(按需增删);bootstrap 幂等(主页锚可见即成功),超时输出页面快照供分诊。
|
|
39
|
+
|
|
40
|
+
GATES = [
|
|
41
|
+
{"name": "privacy", "texts": ["同意并继续", "同意", "知道了", "我知道了"]},
|
|
42
|
+
{"name": "permission", "texts": ["仅在使用中允许", "使用应用时允许", "允许"]},
|
|
43
|
+
{"name": "ad", "texts": ["跳过", "关闭广告", "不再显示", "以后再说", "暂不"]},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
def _page_texts(d):
|
|
47
|
+
try:
|
|
48
|
+
return [(c.getText() or '') for c in (d.find_all_components(BY.type("Text")) or [])
|
|
49
|
+
if c.getText()]
|
|
50
|
+
except Exception:
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _visible_clickables(d):
|
|
55
|
+
"""一次拉全 Text+Button 可见文案集合(弹窗消化轮询的单 RPC 扫描,
|
|
56
|
+
替代逐文案 find_component 的 12+ RPC/轮——冷启消化从 ~3s/轮 降到 ~0.6s/轮)。"""
|
|
57
|
+
out = []
|
|
58
|
+
try:
|
|
59
|
+
for by in (BY.type("Text"), BY.type("Button")):
|
|
60
|
+
for c in (d.find_all_components(by) or []):
|
|
61
|
+
t = c.getText() or ""
|
|
62
|
+
if t:
|
|
63
|
+
out.append(t)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
def bootstrap(d, gates=None, home_tab="", anchor_key="", total=60, log=print):
|
|
69
|
+
"""冷启到可用基座。流程:轮询消化全部弹窗关卡(一轮多点,直到无特征)→
|
|
70
|
+
tab 归位(点目标 tab + 验证锚)→ 主页锚可见返回 True。
|
|
71
|
+
anchor_key 是"基座就绪"的唯一判据(app 级配置,如目标 tab 的内容控件 id)。"""
|
|
72
|
+
gates = gates if gates is not None else GATES
|
|
73
|
+
all_texts = [t for g in gates for t in g["texts"]]
|
|
74
|
+
t0 = time.time()
|
|
75
|
+
dismissed = []
|
|
76
|
+
while time.time() - t0 < total:
|
|
77
|
+
# 1) 主页锚已可见 → 提前成功(幂等)
|
|
78
|
+
if anchor_key:
|
|
79
|
+
try:
|
|
80
|
+
if d.find_component(BY.key(anchor_key)) is not None:
|
|
81
|
+
if dismissed:
|
|
82
|
+
log(f" [L0] 弹窗消化: {dismissed}")
|
|
83
|
+
return True
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
# 2) 消化所有弹窗关卡:一轮拉全可见文案(单 RPC 扫描),本地匹配命中再点击
|
|
87
|
+
hit_any = False
|
|
88
|
+
vis = set(_visible_clickables(d))
|
|
89
|
+
for t in all_texts:
|
|
90
|
+
if t == (home_tab or "") or t not in vis:
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
d.touch(BY.text(t))
|
|
94
|
+
dismissed.append(t)
|
|
95
|
+
hit_any = True
|
|
96
|
+
time.sleep(1.2)
|
|
97
|
+
break # 点一个后重新扫(弹窗可能叠加/消失后露下一个)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
if hit_any:
|
|
101
|
+
continue
|
|
102
|
+
pts = _page_texts(d)
|
|
103
|
+
# 3) 无弹窗特征:tab 归位(点目标 tab,锚验证)
|
|
104
|
+
if home_tab and home_tab in pts:
|
|
105
|
+
if anchor_key:
|
|
106
|
+
try:
|
|
107
|
+
if d.find_component(BY.key(anchor_key)) is not None:
|
|
108
|
+
return True
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
try:
|
|
112
|
+
d.touch(BY.text(home_tab))
|
|
113
|
+
time.sleep(2.0)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
time.sleep(0.9)
|
|
117
|
+
# 超时:输出页面快照供分诊
|
|
118
|
+
log(f" [L0] ✗ bootstrap 超时 {total}s;页面文本快照: {_page_texts(d)[:15]}")
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Level 0 前置三件套(TD-007:冷启自适应,用例不得自带)────────────────
|
|
123
|
+
# ① 首启弹窗 dismiss:多文案轮询(隐私/权限/引导),幂等无弹窗秒过
|
|
124
|
+
FIRST_RUN_DISMISS_TEXTS = ["同意并继续", "同意", "允许", "仅在使用中允许", "我知道了", "知道了", "暂不", "以后再说"]
|
|
125
|
+
|
|
126
|
+
def dismiss_first_run(d, rounds=3, gap=1.2):
|
|
127
|
+
for _ in range(rounds):
|
|
128
|
+
hit = False
|
|
129
|
+
for t in FIRST_RUN_DISMISS_TEXTS:
|
|
130
|
+
try:
|
|
131
|
+
c = d.find_component(BY.text(t))
|
|
132
|
+
if c is not None:
|
|
133
|
+
d.touch(BY.text(t)); time.sleep(gap); hit = True; break
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
if not hit:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# ② 首页 tab 归位:应用默认落在非目标 tab(如广场)时点回目标 tab(HOME_TAB 应用级配置)
|
|
140
|
+
def ensure_home_tab(d, tab_text, timeout=10):
|
|
141
|
+
t0 = time.time()
|
|
142
|
+
while time.time() - t0 < timeout:
|
|
143
|
+
try:
|
|
144
|
+
if d.find_component(BY.text(tab_text)) is not None:
|
|
145
|
+
d.touch(BY.text(tab_text)); time.sleep(1.5); return True
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
time.sleep(0.8)
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
# ③ 冷启三件套:launch 内自动执行——弹窗 dismiss + tab 归位(HOME_TAB 非空时)
|
|
152
|
+
HOME_TAB = "" # 应用级:主功能 tab 文案(如 "玩安卓");空串跳过归位
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def launch(d, want="", reset_data=False, force=False):
|
|
156
|
+
"""v3-①②(EXEC-034):want=启动参数('k=v;k2=v2' → hdc aa start --ps 透传,启动参数门控应用免人工补丁);
|
|
157
|
+
reset_data=True → bm clean -n 清应用数据(用例级状态重置,防跨用例泄漏);
|
|
158
|
+
force=True → 强制冷启(断言冷启行为的用例用)。
|
|
159
|
+
热启动(run_direct 组内非首条置 FORGE_WARM_LAUNCH=1):HOME_ANCHOR 已在位即软复用
|
|
160
|
+
当前会话跳过 stop/start/bootstrap(省冷启 8-12s/例);锚不可见自动回退完整冷启。"""
|
|
161
|
+
import os as _os, subprocess as _sp
|
|
162
|
+
if not want and not reset_data and not force and _os.environ.get("FORGE_WARM_LAUNCH") == "1":
|
|
163
|
+
anchor = globals().get("HOME_ANCHOR", "")
|
|
164
|
+
if anchor:
|
|
165
|
+
try:
|
|
166
|
+
if d.find_component(BY.key(anchor)) is not None:
|
|
167
|
+
return # 基座已就位:软复用(上一用例恰好停在本页)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
d.stop_app(PACKAGE)
|
|
171
|
+
time.sleep(0.8)
|
|
172
|
+
if reset_data:
|
|
173
|
+
hdc = _os.environ.get("FORGE_HDC", "hdc")
|
|
174
|
+
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
175
|
+
try:
|
|
176
|
+
# DEVIATION-3: bm clean 不带 -d 时仅清 cache(-c),持久化不清,用例间状态泄漏
|
|
177
|
+
# (TC02 关 answer 残留致 TC03/08/09 连锁失败);本机 bm 无 -b 选项,-d 清数据
|
|
178
|
+
_sp.run([hdc, "-t", dev, "shell", "bm", "clean", "-n", PACKAGE, "-d"],
|
|
179
|
+
capture_output=True, timeout=15)
|
|
180
|
+
time.sleep(0.5)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
print(" [reset] bm clean 失败:", e)
|
|
183
|
+
if want:
|
|
184
|
+
hdc = _os.environ.get("FORGE_HDC", "hdc")
|
|
185
|
+
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
186
|
+
args = ["aa", "start", "-b", PACKAGE, "-a", ABILITY]
|
|
187
|
+
for kv in want.split(";"):
|
|
188
|
+
if "=" in kv:
|
|
189
|
+
k, v = kv.split("=", 1)
|
|
190
|
+
# DEVIATION-1: aa start 的 --ps 只接受空格分隔 key value,等号形式报
|
|
191
|
+
# "invalid number of parameters for option --ps" 导致带参启动全部失败
|
|
192
|
+
args += ["--ps", k, v]
|
|
193
|
+
# EXEC-038 修复:带参启动 3 轮重试 + 实测探活(FORGE_WANT_PROBE=key,可寻才算活)
|
|
194
|
+
probe = _os.environ.get("FORGE_WANT_PROBE", "")
|
|
195
|
+
for attempt in range(3):
|
|
196
|
+
try:
|
|
197
|
+
_sp.run([hdc, "-t", dev, "shell", "aa", "force-stop", PACKAGE], capture_output=True, timeout=10)
|
|
198
|
+
time.sleep(0.8)
|
|
199
|
+
_sp.run([hdc, "-t", dev, "shell"] + args, capture_output=True, timeout=15)
|
|
200
|
+
time.sleep(3.0)
|
|
201
|
+
if probe:
|
|
202
|
+
c = None
|
|
203
|
+
try:
|
|
204
|
+
c = d.find_component(BY.key(probe))
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
if c is None:
|
|
208
|
+
print(f" [want] 第{attempt+1}轮探活失败(key={probe}),重试")
|
|
209
|
+
time.sleep(1.5)
|
|
210
|
+
continue
|
|
211
|
+
_dismiss_first_run(d)
|
|
212
|
+
return
|
|
213
|
+
except Exception as e:
|
|
214
|
+
print(" [want] 带参启动异常,重试:", e)
|
|
215
|
+
time.sleep(1.5)
|
|
216
|
+
print(" [want] 3 轮未探活成功,继续交由断言暴露")
|
|
217
|
+
d.start_app(PACKAGE, ABILITY)
|
|
218
|
+
ok = bootstrap(d, home_tab=HOME_TAB, anchor_key=HOME_ANCHOR)
|
|
219
|
+
if not ok:
|
|
220
|
+
raise AssertionError("L0 bootstrap failed(弹窗/tab 未消化,见日志快照)")
|
|
221
|
+
time.sleep(1.5) # bootstrap 锚已验证就位,1.5s 落座余量(原 2.5s)
|
|
222
|
+
_dismiss_first_run(d)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _dismiss_first_run(d):
|
|
226
|
+
"""首启弹窗一键处置(权限/隐私协议)——单 RPC 拉全文案本地匹配"""
|
|
227
|
+
vis = set(_visible_clickables(d))
|
|
228
|
+
for txt in ["同意并继续", "允许", "我知道了", "暂不", "以后再说", "同意"]:
|
|
229
|
+
if txt not in vis:
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
c = d.find_component(BY.text(txt))
|
|
233
|
+
if c:
|
|
234
|
+
c.click()
|
|
235
|
+
time.sleep(0.8)
|
|
236
|
+
vis = set(_visible_clickables(d)) # 点掉一层后重扫(可能叠弹窗)
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def stop(d):
|
|
242
|
+
d.stop_app(PACKAGE)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def nav_to(d, page):
|
|
246
|
+
"""导航到目标页:入口页直接用;非入口页用返回键回根后再走用例自带链(由用例 steps 驱动)"""
|
|
247
|
+
if page == ENTRY_PAGE:
|
|
248
|
+
return
|
|
249
|
+
# 返回根页(最多 5 次)
|
|
250
|
+
for _ in range(5):
|
|
251
|
+
try:
|
|
252
|
+
if d.find_component(BY.key("__never__")) is None:
|
|
253
|
+
pass
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
d.press_back()
|
|
257
|
+
time.sleep(0.4)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
TAB_LABELS = ["首页", "视频", "我的"]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def click_tab(d, label):
|
|
264
|
+
"""底部 Tab 切换:定位文本→bounds 中心 touch(比例坐标兜底)"""
|
|
265
|
+
import time as _t
|
|
266
|
+
for _ in range(8):
|
|
267
|
+
try:
|
|
268
|
+
comps = d.find_all_components(BY.text(label))
|
|
269
|
+
if comps:
|
|
270
|
+
c = comps[-1] # 底部 tab 是同名文本中最靠下的
|
|
271
|
+
b = c.getBounds()
|
|
272
|
+
cx = (b.left + b.right) // 2
|
|
273
|
+
cy = (b.top + b.bottom) // 2
|
|
274
|
+
d.touch((int(cx), int(cy)))
|
|
275
|
+
_t.sleep(1.2)
|
|
276
|
+
return
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
_t.sleep(0.25)
|
|
280
|
+
idx = TAB_LABELS.index(label) if label in TAB_LABELS else 2
|
|
281
|
+
w, h = d.get_display_size()
|
|
282
|
+
d.touch((int(w * (idx + 0.5) / len(TAB_LABELS)), int(h * 0.945)))
|
|
283
|
+
time.sleep(1.2)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def click_text(d, txt, wait=6):
|
|
287
|
+
"""按可见文本点击(tab 名/中文按钮等无 ID 控件)。
|
|
288
|
+
先试 BY.key——无下划线的单词 id(如 fab)无法与文本区分(_is_id_like 局限),此处兜底。"""
|
|
289
|
+
import time as _t
|
|
290
|
+
try:
|
|
291
|
+
c = d.find_component(BY.key(txt))
|
|
292
|
+
if c:
|
|
293
|
+
c.click()
|
|
294
|
+
_t.sleep(1.0)
|
|
295
|
+
return
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
deadline = _t.time() + _w(wait)
|
|
299
|
+
while _t.time() < deadline:
|
|
300
|
+
try:
|
|
301
|
+
c = d.find_component(BY.text(txt, MatchPattern.CONTAINS))
|
|
302
|
+
if c:
|
|
303
|
+
c.click()
|
|
304
|
+
_t.sleep(1.0)
|
|
305
|
+
return
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
_t.sleep(0.25)
|
|
309
|
+
raise AssertionError("text not clickable: " + txt)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def click_type_first(d, ctype, wait=6):
|
|
313
|
+
"""点击指定类型的第一个组件(lineN:Type 引用的兜底点击)"""
|
|
314
|
+
import time as _t
|
|
315
|
+
deadline = _t.time() + _w(wait)
|
|
316
|
+
while _t.time() < deadline:
|
|
317
|
+
try:
|
|
318
|
+
comps = d.find_all_components(BY.type(ctype))
|
|
319
|
+
if comps:
|
|
320
|
+
b = comps[0].getBounds()
|
|
321
|
+
d.touch(((b.left + b.right) // 2, (b.top + b.bottom) // 2))
|
|
322
|
+
_t.sleep(1.2)
|
|
323
|
+
return
|
|
324
|
+
except Exception:
|
|
325
|
+
pass
|
|
326
|
+
_t.sleep(0.25)
|
|
327
|
+
raise AssertionError("type not clickable: " + ctype)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def id_click(d, cid, wait=6):
|
|
331
|
+
comp = _find(d, cid, wait)
|
|
332
|
+
comp.click()
|
|
333
|
+
time.sleep(1.0)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def id_input(d, cid, text, wait=6):
|
|
337
|
+
comp = _find(d, cid, wait)
|
|
338
|
+
d.input_text(comp, text)
|
|
339
|
+
time.sleep(0.6)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _find(d, cid, wait=6):
|
|
343
|
+
import time as _t
|
|
344
|
+
deadline = _t.time() + _w(wait)
|
|
345
|
+
while _t.time() < deadline:
|
|
346
|
+
try:
|
|
347
|
+
c = d.find_component(BY.key(cid))
|
|
348
|
+
if c:
|
|
349
|
+
return c
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
try:
|
|
353
|
+
c = d.find_component(BY.id(cid))
|
|
354
|
+
if c:
|
|
355
|
+
return c
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
_t.sleep(0.25)
|
|
359
|
+
raise AssertionError("component not found: " + cid)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def smart_click(d, cid, txt=None, wait=6):
|
|
363
|
+
"""key 优先点击;失败且有 txt 时文本点击(mine 类 HAR 模块 ID 丢失边界)"""
|
|
364
|
+
import time as _t
|
|
365
|
+
deadline = _t.time() + _w(wait)
|
|
366
|
+
while _t.time() < deadline:
|
|
367
|
+
try:
|
|
368
|
+
c = d.find_component(BY.key(cid))
|
|
369
|
+
if c:
|
|
370
|
+
c.click()
|
|
371
|
+
_t.sleep(1.0)
|
|
372
|
+
return
|
|
373
|
+
except Exception:
|
|
374
|
+
pass
|
|
375
|
+
_t.sleep(0.25)
|
|
376
|
+
if txt and txt.startswith("line"):
|
|
377
|
+
import re as _re
|
|
378
|
+
m = _re.match(r"line\d+:(\w+)", txt)
|
|
379
|
+
if m:
|
|
380
|
+
click_type_first(d, m.group(1), wait)
|
|
381
|
+
return
|
|
382
|
+
if txt:
|
|
383
|
+
click_text(d, txt, wait)
|
|
384
|
+
return
|
|
385
|
+
raise AssertionError("component not found: " + cid)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def scroll_text_exist(d, txt, wait=6):
|
|
389
|
+
"""滚动查找文本,双向(EXEC-005):上滑 N 次找 → 未中则下滑回落 N 次找。
|
|
390
|
+
单向滚动在连续断言场景必挂:上一断言滑到底后,本断言目标在上方,单向永远回不去。"""
|
|
391
|
+
import time as _t
|
|
392
|
+
w, h = d.get_display_size()
|
|
393
|
+
def _check():
|
|
394
|
+
try:
|
|
395
|
+
return d.find_component(BY.text(txt, MatchPattern.CONTAINS)) is not None
|
|
396
|
+
except Exception:
|
|
397
|
+
return False
|
|
398
|
+
for direction in (UiParam.UP, UiParam.DOWN):
|
|
399
|
+
for i in range(6):
|
|
400
|
+
if _check():
|
|
401
|
+
return True
|
|
402
|
+
d.swipe(direction, distance=40)
|
|
403
|
+
_t.sleep(1.0)
|
|
404
|
+
return text_exist(d, txt, wait)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def smart_check(d, cid, txt=None, wait=6):
|
|
408
|
+
"""key 优先断言;失败且有 txt 时降级文本断言(部分 HAR 模块 .id 运行时丢弃的边界)"""
|
|
409
|
+
import time as _t
|
|
410
|
+
deadline = _t.time() + _w(wait)
|
|
411
|
+
while _t.time() < deadline:
|
|
412
|
+
try:
|
|
413
|
+
c = d.find_component(BY.key(cid))
|
|
414
|
+
if c:
|
|
415
|
+
return True
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
418
|
+
_t.sleep(0.25)
|
|
419
|
+
if txt and txt.startswith("line"):
|
|
420
|
+
import re as _re
|
|
421
|
+
m = _re.match(r"line\d+:(\w+)", txt)
|
|
422
|
+
if m:
|
|
423
|
+
d.check_component_exist(BY.type(m.group(1)), wait_time=wait)
|
|
424
|
+
return True
|
|
425
|
+
if txt:
|
|
426
|
+
return text_exist(d, txt, wait)
|
|
427
|
+
raise AssertionError("component not found: " + cid)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def text_equals(d, txt, wait=6):
|
|
431
|
+
"""精确文本断言(EQUALS)——CONTAINS 会被前缀污染(如 ❄️Gallery 含 Gallery),
|
|
432
|
+
恢复/状态标记类断言必须用本原语。存在且精确相等返回 True,否则抛。"""
|
|
433
|
+
import time as _t
|
|
434
|
+
deadline = _t.time() + _w(wait)
|
|
435
|
+
while _t.time() < deadline:
|
|
436
|
+
try:
|
|
437
|
+
c = d.find_component(BY.text(txt))
|
|
438
|
+
if c:
|
|
439
|
+
return True
|
|
440
|
+
except Exception:
|
|
441
|
+
pass
|
|
442
|
+
_t.sleep(0.25)
|
|
443
|
+
raise AssertionError("text (exact) not found: " + txt)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def text_absent(d, txt, wait=3):
|
|
447
|
+
"""文本不存在断言(反向;等待短暂稳定后确认消失)"""
|
|
448
|
+
import time as _t
|
|
449
|
+
_t.sleep(1.0)
|
|
450
|
+
try:
|
|
451
|
+
c = d.find_component(BY.text(txt, MatchPattern.CONTAINS))
|
|
452
|
+
if c:
|
|
453
|
+
raise AssertionError("text should be absent but found: " + txt)
|
|
454
|
+
except AssertionError:
|
|
455
|
+
raise
|
|
456
|
+
except Exception:
|
|
457
|
+
pass
|
|
458
|
+
return True
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def step_shot(d, label="step"):
|
|
462
|
+
"""分步截图:一个动作/断言一张页面图(run_direct 设 FORGE_SHOT_DIR/FORGE_CASE 时启用)。
|
|
463
|
+
文件名 <case>_<序号>_<label>.jpeg,供 test-report.html 按步回放页面。
|
|
464
|
+
FORGE_SHOT_MODE=off 时跳过;fail 模式下 PASS 用例的过程图由 run_direct 统一清理。"""
|
|
465
|
+
import os as _os
|
|
466
|
+
if _os.environ.get("FORGE_SHOT_MODE") == "off":
|
|
467
|
+
return
|
|
468
|
+
base = _os.environ.get("FORGE_SHOT_DIR")
|
|
469
|
+
case = _os.environ.get("FORGE_CASE", "case")
|
|
470
|
+
if not base:
|
|
471
|
+
return
|
|
472
|
+
n = step_shot.n.get(case, 0) + 1
|
|
473
|
+
try:
|
|
474
|
+
d.screenshot(_os.path.join(base, "%s_%02d_%s.jpeg" % (case, n, label)))
|
|
475
|
+
step_shot.n[case] = n
|
|
476
|
+
except Exception:
|
|
477
|
+
pass
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
step_shot.n = {}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def wait_for(d, by, timeout=10, interval=0.5):
|
|
484
|
+
"""轮询等待控件出现(首屏锚点等待用);返回组件或 None,不抛异常"""
|
|
485
|
+
import time as _t
|
|
486
|
+
end = _t.time() + _w(timeout)
|
|
487
|
+
while _t.time() < end:
|
|
488
|
+
try:
|
|
489
|
+
c = d.find_component(by)
|
|
490
|
+
if c:
|
|
491
|
+
return c
|
|
492
|
+
except Exception:
|
|
493
|
+
pass
|
|
494
|
+
_t.sleep(interval)
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def text_exist(d, txt, wait=6):
|
|
499
|
+
"""文本存在断言(BY.text CONTAINS)。
|
|
500
|
+
契约:存在返回 True;不存在【抛 AssertionError】——永不返回 False。
|
|
501
|
+
run_direct/analyze_results 依赖异常文本做 FAIL 判定与失败聚类,勿改为静默返回。"""
|
|
502
|
+
import time as _t
|
|
503
|
+
deadline = _t.time() + _w(wait)
|
|
504
|
+
while _t.time() < deadline:
|
|
505
|
+
try:
|
|
506
|
+
c = d.find_component(BY.text(txt, MatchPattern.CONTAINS))
|
|
507
|
+
if c:
|
|
508
|
+
return True
|
|
509
|
+
except Exception:
|
|
510
|
+
pass
|
|
511
|
+
_t.sleep(0.25)
|
|
512
|
+
raise AssertionError("text not found: " + txt)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def text_value_equals(d, cid, expected, wait=6):
|
|
516
|
+
"""值断言(EXEC-040 实证+反证):getText() 精确等于期望——锚定 key 控件动态内容。"""
|
|
517
|
+
import time as _t
|
|
518
|
+
deadline = _t.time() + _w(wait)
|
|
519
|
+
while _t.time() < deadline:
|
|
520
|
+
try:
|
|
521
|
+
c = d.find_component(BY.key(cid))
|
|
522
|
+
if c and c.getText() == expected:
|
|
523
|
+
return True
|
|
524
|
+
except Exception:
|
|
525
|
+
pass
|
|
526
|
+
_t.sleep(0.25)
|
|
527
|
+
got = None
|
|
528
|
+
try:
|
|
529
|
+
c = d.find_component(BY.key(cid))
|
|
530
|
+
got = c.getText() if c else '(not found)'
|
|
531
|
+
except Exception:
|
|
532
|
+
pass
|
|
533
|
+
raise AssertionError(f"text value mismatch: expect {expected!r} got {got!r} (key={cid})")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def text_value_contains(d, cid, part, wait=6):
|
|
537
|
+
"""值包含断言:getText() 包含子串(动态文案前缀不定时用)。"""
|
|
538
|
+
import time as _t
|
|
539
|
+
deadline = _t.time() + _w(wait)
|
|
540
|
+
while _t.time() < deadline:
|
|
541
|
+
try:
|
|
542
|
+
c = d.find_component(BY.key(cid))
|
|
543
|
+
if c and part in (c.getText() or ''):
|
|
544
|
+
return True
|
|
545
|
+
except Exception:
|
|
546
|
+
pass
|
|
547
|
+
_t.sleep(0.25)
|
|
548
|
+
raise AssertionError(f"text value not containing {part!r} (key={cid})")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def count_equals(d, target, expected, wait=6):
|
|
552
|
+
"""计数断言(EXEC-040 实证):key 或 CONTAINS 文本匹配的组件数==期望。"""
|
|
553
|
+
import time as _t, re as _re
|
|
554
|
+
deadline = _t.time() + _w(wait)
|
|
555
|
+
last = -1
|
|
556
|
+
while _t.time() < deadline:
|
|
557
|
+
try:
|
|
558
|
+
if _re.match(r'^[a-z][a-z0-9_]+$', target):
|
|
559
|
+
comps = d.find_all_components(BY.key(target))
|
|
560
|
+
else:
|
|
561
|
+
comps = d.find_all_components(BY.text(target, MatchPattern.CONTAINS))
|
|
562
|
+
last = len(comps) if comps else 0
|
|
563
|
+
if last == expected:
|
|
564
|
+
return True
|
|
565
|
+
except Exception:
|
|
566
|
+
pass
|
|
567
|
+
_t.sleep(0.25)
|
|
568
|
+
raise AssertionError(f"count mismatch: expect {expected} got {last} (target={target})")
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def is_below(d, cid, ref_cid, wait=6):
|
|
572
|
+
"""几何断言(EXEC-040 实证):cid 的 top >= ref_cid 的 bottom(垂直次序)。"""
|
|
573
|
+
import time as _t
|
|
574
|
+
deadline = _t.time() + _w(wait)
|
|
575
|
+
while _t.time() < deadline:
|
|
576
|
+
try:
|
|
577
|
+
a = d.find_component(BY.key(cid)); b = d.find_component(BY.key(ref_cid))
|
|
578
|
+
if a and b and a.getBounds().top >= b.getBounds().bottom:
|
|
579
|
+
return True
|
|
580
|
+
except Exception:
|
|
581
|
+
pass
|
|
582
|
+
_t.sleep(0.25)
|
|
583
|
+
raise AssertionError(f"geometry: {cid} not below {ref_cid}")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def toast_exist(d, txt, wait=4, poll=0.5):
|
|
587
|
+
"""Toast 断言(v3.1):轮询 hilog 捕获 Toast 文本。需 FORGE_HDC/FORGE_DEVICE。"""
|
|
588
|
+
import os as _os, subprocess as _sp, time as _tt
|
|
589
|
+
hdc = _os.environ.get("FORGE_HDC", "hdc"); dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
590
|
+
deadline = _tt.time() + _w(wait)
|
|
591
|
+
while _tt.time() < deadline:
|
|
592
|
+
try:
|
|
593
|
+
r = _sp.run([hdc, "-t", dev, "shell", "hilog", "-x"],
|
|
594
|
+
capture_output=True, timeout=8, text=True, errors="ignore")
|
|
595
|
+
if txt in (r.stdout or ""):
|
|
596
|
+
return True
|
|
597
|
+
except Exception:
|
|
598
|
+
pass
|
|
599
|
+
_tt.sleep(poll)
|
|
600
|
+
raise AssertionError("toast not found in hilog: " + txt)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
# ── 强断言原语(2026-09-29 吸收自 none 臂实证:无 skill 的 LLM 凭通用能力即达成 34% 强断言,
|
|
604
|
+
# B 线无理由低于此——Toast/系统事件/数据断言按下表选型,禁止只写 exist)──
|
|
605
|
+
def _hdc(args, timeout=20):
|
|
606
|
+
import subprocess as _sp, os as _os
|
|
607
|
+
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
608
|
+
cmd = [HDC_BIN, "-t", dev] + args
|
|
609
|
+
r = _sp.run(cmd, capture_output=True, timeout=timeout)
|
|
610
|
+
return (r.stdout + r.stderr).decode("utf-8", "ignore")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _dump_layout(timeout=20):
|
|
614
|
+
"""取当前全量布局 JSON(含系统对话框/浮层),返回原始文本(Round-2:确保本地 tmp 目录存在)"""
|
|
615
|
+
import json as _json, os as _os
|
|
616
|
+
_os.makedirs("./tmp", exist_ok=True)
|
|
617
|
+
_hdc(["shell", "uitest", "dumpLayout", "-p", "/data/local/tmp/forge_dump.json"], timeout=timeout)
|
|
618
|
+
_hdc(["file", "recv", "/data/local/tmp/forge_dump.json", "./tmp/forge_dump.json"], timeout=timeout)
|
|
619
|
+
try:
|
|
620
|
+
return open("./tmp/forge_dump.json", encoding="utf-8").read()
|
|
621
|
+
except Exception:
|
|
622
|
+
return ""
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def hilog_clear():
|
|
626
|
+
"""清 hilog 缓冲,防止上一用例残留关键字造成假 PASS(tag=RaiseToAnswer)"""
|
|
627
|
+
_hdc(["shell", "hilog", "-r"])
|
|
628
|
+
time.sleep(0.3)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
HILOG_TAG = 'JSAPP'
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def hilog_tail(tag=None, n=400):
|
|
635
|
+
tag = tag or HILOG_TAG
|
|
636
|
+
"""读取 tag 过滤的最近 n 行 hilog(诊断用)"""
|
|
637
|
+
import subprocess as _sp, os as _os
|
|
638
|
+
dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
639
|
+
r = _sp.run([HDC_BIN, "-t", dev, "shell", "hilog", "-z", str(n), "-T", tag],
|
|
640
|
+
capture_output=True, timeout=15)
|
|
641
|
+
return r.stdout.decode("utf-8", "ignore")
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def toast_text_seen(d, txt, timeout=6):
|
|
645
|
+
"""Toast 文本断言(dump 版)。无 ID 例外:Toast 为瞬时浮层无稳定 ID,且 hypium find_component
|
|
646
|
+
实测看不到 Toast 子窗口,故以 uitest dumpLayout 原文轮询(首轮实测教训 Round-1)。"""
|
|
647
|
+
deadline = time.time() + _w(timeout)
|
|
648
|
+
while time.time() < deadline:
|
|
649
|
+
raw = _dump_layout()
|
|
650
|
+
if txt in raw:
|
|
651
|
+
return True
|
|
652
|
+
time.sleep(0.6)
|
|
653
|
+
raise AssertionError("toast not seen: " + txt)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def bm_clean():
|
|
657
|
+
"""清应用持久化数据(公开包装;import * 不导出 _ 前缀——Round-1 教训)"""
|
|
658
|
+
_hdc(["shell", "bm", "clean", "-n", PACKAGE, "-d"])
|
|
659
|
+
time.sleep(0.6)
|