@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,249 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
merge_appgraph.py — AppGraph(analyze android) facts × forge scan_features 语义合并。
|
|
4
|
+
|
|
5
|
+
定位:scan_features 只见鸿蒙侧静态树(.id/行号/if 分支),缺行为语义;
|
|
6
|
+
AppGraph facts 是安卓权威行为账本(控件/入口/事件/handler/callTrace/导航边/trigger 分类),
|
|
7
|
+
但无鸿蒙侧定位事实。本脚本按 token 集合 join(answerFeature ↔ feature_answer_switch)
|
|
8
|
+
把 Android 语义注入 features.json,产出同名 schema 的增强版,供 evaluate_coverage /
|
|
9
|
+
用例设计(handler 名直接揭示联动语义)/ agent 融合(ref 体系对齐迁移报告)消费。
|
|
10
|
+
|
|
11
|
+
用法:
|
|
12
|
+
merge_appgraph.py --features features.json --facts <android>/.migration/spec/facts \
|
|
13
|
+
[--out features.merged.json]
|
|
14
|
+
无 AppGraph facts 时退出码 2(调用方回退纯 scan_features)。
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import glob
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tokens(s):
|
|
24
|
+
"""camelCase + snake_case 统一切词:answerFeature 与 feature_answer → {answer, feature}。"""
|
|
25
|
+
parts = [p.lower() for p in re.findall(r'[A-Z]?[a-z0-9]+', str(s or ''))]
|
|
26
|
+
return set(parts) - {'menu', 'click', 'button', 'entry', 'the', 'id', 'app'}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def norm_name(s):
|
|
30
|
+
return re.sub(r'[^a-z0-9]', '', str(s or '').lower())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def join_key_candidates(name):
|
|
34
|
+
t = tokens(name)
|
|
35
|
+
return t, norm_name(name)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def match(names, pool, event=None):
|
|
39
|
+
"""names: 候选语义名(carrier/label/event 提取);规则:norm 相等 > token 全等 >
|
|
40
|
+
android ⊂ forge id(容许 _switch 类小后缀);checked 类事件优先 *_switch 目标。"""
|
|
41
|
+
for name in names:
|
|
42
|
+
if not name:
|
|
43
|
+
continue
|
|
44
|
+
ct, cn = tokens(name), norm_name(name)
|
|
45
|
+
if not ct and not cn:
|
|
46
|
+
continue
|
|
47
|
+
for ft in sorted(pool):
|
|
48
|
+
if cn and norm_name(ft) == cn:
|
|
49
|
+
return ft
|
|
50
|
+
cands = []
|
|
51
|
+
for ft in sorted(pool):
|
|
52
|
+
ftt = tokens(ft)
|
|
53
|
+
if not ftt or not ct:
|
|
54
|
+
continue
|
|
55
|
+
if ct == ftt:
|
|
56
|
+
cands.append((0, ft))
|
|
57
|
+
elif ct < ftt and len(ftt) <= len(ct) + 2:
|
|
58
|
+
cands.append((1, ft))
|
|
59
|
+
if not cands:
|
|
60
|
+
continue
|
|
61
|
+
cands.sort()
|
|
62
|
+
if event and str(event).startswith('checked'):
|
|
63
|
+
for _, ft in cands:
|
|
64
|
+
if 'switch' in ft or 'toggle' in ft:
|
|
65
|
+
return ft
|
|
66
|
+
return cands[0][1]
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def event_tokens(event):
|
|
71
|
+
"""menu-click:privacy_policy → ['privacy_policy'];checked-change → []。"""
|
|
72
|
+
m = re.match(r'^menu-click:(\w+)$', str(event or ''))
|
|
73
|
+
return [m.group(1)] if m else []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def harvest_ids(project_dir):
|
|
77
|
+
"""收割工程内可静态定位的 ID:.id('x') 字面量 + 组件参数 *Id: 'x'(如 rowId)。
|
|
78
|
+
模板拼接 .id(`${rowId}_switch`) 无法静态解析,由 prop 值 + 已知后缀兜底。"""
|
|
79
|
+
pool = {}
|
|
80
|
+
root = os.path.join(project_dir or '.', '')
|
|
81
|
+
for path in glob.glob(os.path.join(root, '**', '*.ets'), recursive=True):
|
|
82
|
+
if os.sep + 'ohosTest' in path or os.sep + '.hypium_forge' in path:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
src = open(path, encoding='utf-8').read()
|
|
86
|
+
except Exception:
|
|
87
|
+
continue
|
|
88
|
+
for m in re.finditer(r"\.id\(\s*['\"`]([\w-]+)['\"`]\s*\)", src):
|
|
89
|
+
line = src[:m.start()].count('\n') + 1
|
|
90
|
+
pool.setdefault(m.group(1), {'file': os.path.relpath(path, root), 'line': line})
|
|
91
|
+
for m in re.finditer(r"\b\w*[Ii]d\s*:\s*['\"]([\w-]+)['\"]", src):
|
|
92
|
+
for cand in (m.group(1), f"{m.group(1)}_switch"):
|
|
93
|
+
pool.setdefault(cand, {'file': os.path.relpath(path, root), 'line': src[:m.start()].count('\n') + 1})
|
|
94
|
+
return pool
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_appgraph(facts_dir):
|
|
98
|
+
controls, entries, nav = [], [], []
|
|
99
|
+
inter_path = os.path.join(facts_dir, 'interactions.json')
|
|
100
|
+
if os.path.isdir(facts_dir):
|
|
101
|
+
pages = sorted(glob.glob(os.path.join(facts_dir, 'pages', '*.json')))
|
|
102
|
+
else:
|
|
103
|
+
pages = []
|
|
104
|
+
for p in pages:
|
|
105
|
+
if os.path.basename(p) == 'index.json' or os.path.basename(p) == 'unassigned.json':
|
|
106
|
+
continue
|
|
107
|
+
try:
|
|
108
|
+
d = json.load(open(p, encoding='utf-8'))
|
|
109
|
+
except Exception:
|
|
110
|
+
continue
|
|
111
|
+
controls += d.get('controls', []) or []
|
|
112
|
+
entries += d.get('entries', []) or []
|
|
113
|
+
if os.path.exists(inter_path):
|
|
114
|
+
try:
|
|
115
|
+
nav = json.load(open(inter_path, encoding='utf-8')).get('interactions', []) or []
|
|
116
|
+
except Exception:
|
|
117
|
+
nav = []
|
|
118
|
+
return controls, entries, nav
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main():
|
|
122
|
+
ap = argparse.ArgumentParser()
|
|
123
|
+
ap.add_argument('--features', required=True)
|
|
124
|
+
ap.add_argument('--facts', required=True)
|
|
125
|
+
ap.add_argument('--out', default=None)
|
|
126
|
+
args = ap.parse_args()
|
|
127
|
+
|
|
128
|
+
if not os.path.isdir(args.facts):
|
|
129
|
+
print(f'[x] AppGraph facts 不存在: {args.facts}')
|
|
130
|
+
raise SystemExit(2)
|
|
131
|
+
feats = json.load(open(args.features, encoding='utf-8'))
|
|
132
|
+
controls, entries, nav = load_appgraph(args.facts)
|
|
133
|
+
if not entries and not controls:
|
|
134
|
+
print('[x] facts 为空,回退 scan_features')
|
|
135
|
+
raise SystemExit(2)
|
|
136
|
+
|
|
137
|
+
forge_targets = []
|
|
138
|
+
for pg in feats.get('pages', []):
|
|
139
|
+
for it in pg.get('interactions', []):
|
|
140
|
+
forge_targets.append(it.get('target'))
|
|
141
|
+
id_pool = harvest_ids(feats.get('project_dir', '.'))
|
|
142
|
+
pool = set(forge_targets) | set(id_pool.keys())
|
|
143
|
+
|
|
144
|
+
android_entries = []
|
|
145
|
+
for e in entries:
|
|
146
|
+
rec = {
|
|
147
|
+
'name': e.get('carrierName') or e.get('displayLabel'),
|
|
148
|
+
'event': e.get('event'),
|
|
149
|
+
'handlers': e.get('handlerNames', []) or [],
|
|
150
|
+
'trigger': e.get('triggerCategory'),
|
|
151
|
+
'ref': e.get('entryRef'),
|
|
152
|
+
'label': e.get('displayLabel'),
|
|
153
|
+
'android_evidence': (e.get('sourceEvidence') or [{}])[0].get('file', ''),
|
|
154
|
+
}
|
|
155
|
+
android_entries.append(rec)
|
|
156
|
+
|
|
157
|
+
android_controls = []
|
|
158
|
+
for c in controls:
|
|
159
|
+
android_controls.append({
|
|
160
|
+
'name': c.get('name'),
|
|
161
|
+
'ref': c.get('controlRef'),
|
|
162
|
+
'evidence': (c.get('sourceEvidence') or [{}])[0].get('file', ''),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
matched, unmatched = {}, []
|
|
166
|
+
for ae in android_entries:
|
|
167
|
+
if ae['trigger'] != 'user':
|
|
168
|
+
continue
|
|
169
|
+
cand = [ae['name'], ae['label']] + event_tokens(ae['event'])
|
|
170
|
+
ft = match(cand, pool, ae['event'])
|
|
171
|
+
if ft:
|
|
172
|
+
ae['forge_target'] = ft
|
|
173
|
+
matched.setdefault(ft, []).append(ae)
|
|
174
|
+
else:
|
|
175
|
+
unmatched.append(ae)
|
|
176
|
+
|
|
177
|
+
nav_edges = [{'from': n.get('fromRef'), 'to': n.get('toRef'), 'relation': n.get('relation')}
|
|
178
|
+
for n in nav]
|
|
179
|
+
|
|
180
|
+
enriched_pages = []
|
|
181
|
+
for pg in feats.get('pages', []):
|
|
182
|
+
for it in pg.get('interactions', []):
|
|
183
|
+
hits = matched.get(it.get('target'), [])
|
|
184
|
+
if hits:
|
|
185
|
+
h = hits[0]
|
|
186
|
+
it['android_event'] = h['event']
|
|
187
|
+
it['android_handlers'] = h['handlers']
|
|
188
|
+
it['android_ref'] = h['ref']
|
|
189
|
+
it['android_evidence'] = h['android_evidence']
|
|
190
|
+
enriched_pages.append(pg)
|
|
191
|
+
|
|
192
|
+
entry_path = next((p['path'] for p in enriched_pages if enriched_pages and p['path'] == feats.get('entry', {}).get('page')),
|
|
193
|
+
enriched_pages[0]['path'] if enriched_pages else None)
|
|
194
|
+
entry_idx = next((i for i, p in enumerate(enriched_pages) if p['path'] == entry_path), 0)
|
|
195
|
+
|
|
196
|
+
def owner_unit(h, loc):
|
|
197
|
+
"""归属:checked-change → 拥有 lineN:Toggle 的组件单元(并移除被具体化的 lineN);
|
|
198
|
+
其余 → 入口页。"""
|
|
199
|
+
if str(h['event']).startswith('checked'):
|
|
200
|
+
for i, p in enumerate(enriched_pages):
|
|
201
|
+
for it in p.get('interactions', []):
|
|
202
|
+
if str(it.get('target', '')).startswith('line') and it.get('type') == 'Toggle':
|
|
203
|
+
p['interactions'] = [x for x in p['interactions'] if x is not it]
|
|
204
|
+
return i
|
|
205
|
+
return entry_idx
|
|
206
|
+
|
|
207
|
+
for ft, hits in matched.items():
|
|
208
|
+
if any(ft == it.get('target') for pg in enriched_pages for it in pg.get('interactions', [])):
|
|
209
|
+
continue
|
|
210
|
+
loc = id_pool.get(ft, {})
|
|
211
|
+
h = hits[0]
|
|
212
|
+
enriched_pages[owner_unit(h, loc)]['interactions'].append({
|
|
213
|
+
'kind': 'click' if 'click' in str(h['event']) else 'toggle',
|
|
214
|
+
'target': ft,
|
|
215
|
+
'desc': f"android:{h['name']}({h['event']})",
|
|
216
|
+
'has_id': ft in id_pool,
|
|
217
|
+
'line': loc.get('line', 0),
|
|
218
|
+
'type': 'IdRef',
|
|
219
|
+
'android_event': h['event'],
|
|
220
|
+
'android_handlers': h['handlers'],
|
|
221
|
+
'android_ref': h['ref'],
|
|
222
|
+
'android_evidence': h['android_evidence'],
|
|
223
|
+
'android_file': loc.get('file', ''),
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
feats['pages'] = enriched_pages
|
|
227
|
+
feats['android'] = {
|
|
228
|
+
'controls': android_controls,
|
|
229
|
+
'entries_user': [e for e in android_entries if e['trigger'] == 'user'],
|
|
230
|
+
'entries_system': [e for e in android_entries if e['trigger'] != 'user'],
|
|
231
|
+
'nav_edges': nav_edges,
|
|
232
|
+
'unmatched_user_entries': unmatched,
|
|
233
|
+
'source': args.facts,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
out = args.out or args.features.replace('.json', '.merged.json')
|
|
237
|
+
json.dump(feats, open(out, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
|
|
238
|
+
print(f'合并完成: controls={len(android_controls)} user入口={len(feats["android"]["entries_user"])} '
|
|
239
|
+
f'系统入口={len(feats["android"]["entries_system"])} join命中={len(matched)}/{len(matched)+len(unmatched)} '
|
|
240
|
+
f'导航边={len(nav_edges)} → {out}')
|
|
241
|
+
for ft, hits in sorted(matched.items()):
|
|
242
|
+
for h in hits:
|
|
243
|
+
print(f' [+] {h["name"]}({h["event"]}) → {ft} handlers={h["handlers"]}')
|
|
244
|
+
for u in unmatched:
|
|
245
|
+
print(f' [?] 未join: {u["name"]} ({u["event"]}) handlers={u["handlers"]}')
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == '__main__':
|
|
249
|
+
main()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
precondition.py — 测试前置自动化:权限预授 + 登录/账号策略 + 首启弹窗处理。
|
|
4
|
+
|
|
5
|
+
原则(项目经验 2):
|
|
6
|
+
所有权限和账号默认优先完成确认/登录;无法自动完成的,生成明确的用户提醒清单
|
|
7
|
+
(而非留到用例执行中反复失败)。
|
|
8
|
+
|
|
9
|
+
动作:
|
|
10
|
+
1. 解析 module.json5 权限 → hdc pm grant 预授(按 ACL 级别分类:
|
|
11
|
+
normal/system_basic → 尝试预授;user_grant 运行时权限 → 模拟器上多数可 grant)
|
|
12
|
+
2. 登录页检测 → 三级策略:
|
|
13
|
+
a. mock 登录入口(源码含"模拟登录")→ 自动用之
|
|
14
|
+
b. UI 自动化登录(已知测试账号)→ 提醒用户提供或用环境变量
|
|
15
|
+
c. 不可自动 → 用户提醒清单
|
|
16
|
+
3. 首启弹窗预处置(隐私协议/权限弹窗)→ 输出 dismiss 脚本片段
|
|
17
|
+
|
|
18
|
+
用法:
|
|
19
|
+
precondition.py --features features.json --device 127.0.0.1:5555 [--user-account-env HM_TEST_ACCOUNT]
|
|
20
|
+
输出: preconditions.md(执行结果 + 用户提醒清单)
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
HDC = '/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc'
|
|
30
|
+
|
|
31
|
+
# 鸿蒙权限 → 授予难度(默认可 grant / 需弹窗确认 / 系统级不可授)
|
|
32
|
+
GRANTABLE = re.compile(r'ohos\.permission\.(INTERNET|GET_NETWORK_INFO|KEEP_BACKGROUND_RUNNING|'
|
|
33
|
+
r'REQUIRE_FORM|PUBLISH_AGENT_REMINDER|VIBRATE|QUERY_AUDIO_DEVICE_STATE)')
|
|
34
|
+
# 运行时弹窗类(模拟器可 pm grant)
|
|
35
|
+
RUNTIME = re.compile(r'ohos\.permission\.(READ_|WRITE_|MICROPHONE|CAMERA|LOCATION|MEDIA|'
|
|
36
|
+
r'APPTRACKING|CALENDAR|CONTACTS|DISTRIBUTED)')
|
|
37
|
+
# 用户身份类(不可自动,需用户操作)
|
|
38
|
+
USER_IDENTITY = re.compile(r'ohos\.permission\.(USER_AUTH|MANAGE_FINGERPRINT|ACCESS_USER_AUTH|'
|
|
39
|
+
r'SECURE_SESSION|READ_HEALTH|SPORT)')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def hdc(device, *args, capture=True):
|
|
43
|
+
cmd = [HDC, '-t', device] + list(args)
|
|
44
|
+
try:
|
|
45
|
+
r = subprocess.run(cmd, capture_output=capture, text=True, timeout=30)
|
|
46
|
+
return (r.stdout or '') + (r.stderr or '')
|
|
47
|
+
except Exception as e:
|
|
48
|
+
return f'ERROR: {e}'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main():
|
|
52
|
+
ap = argparse.ArgumentParser()
|
|
53
|
+
ap.add_argument('--features', required=True)
|
|
54
|
+
ap.add_argument('--device', default='127.0.0.1:5555')
|
|
55
|
+
ap.add_argument('--bundle', default=None)
|
|
56
|
+
ap.add_argument('--test-account-env', default='HM_TEST_ACCOUNT',
|
|
57
|
+
help='测试账号环境变量名(格式 user:pass)')
|
|
58
|
+
args = ap.parse_args()
|
|
59
|
+
|
|
60
|
+
feats = json.load(open(args.features, encoding='utf-8'))
|
|
61
|
+
bundle = args.bundle or feats.get('bundle')
|
|
62
|
+
if not bundle:
|
|
63
|
+
print('错误: 无法确定 bundleName', file=sys.stderr)
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
report = [f'# 测试前置报告({bundle} @ {args.device})', '']
|
|
67
|
+
reminders = []
|
|
68
|
+
|
|
69
|
+
# ── 1. 权限预授 ──
|
|
70
|
+
report.append('## 权限预授')
|
|
71
|
+
ok, skipped, user_confirm = [], [], []
|
|
72
|
+
for perm in feats.get('permissions', []):
|
|
73
|
+
if USER_IDENTITY.search(perm):
|
|
74
|
+
user_confirm.append(perm)
|
|
75
|
+
continue
|
|
76
|
+
if GRANTABLE.search(perm) or RUNTIME.search(perm):
|
|
77
|
+
out = hdc(args.device, 'shell', 'atm', 'perm', '-g', bundle, perm)
|
|
78
|
+
granted = 'grant' in out.lower() and 'fail' not in out.lower()
|
|
79
|
+
if not granted:
|
|
80
|
+
out2 = hdc(args.device, 'shell', f'pm grant {bundle} {perm}')
|
|
81
|
+
granted = 'fail' not in out2.lower() and 'Exception' not in out2
|
|
82
|
+
(ok if granted else skipped).append(perm)
|
|
83
|
+
else:
|
|
84
|
+
skipped.append(perm)
|
|
85
|
+
report.append(f'- 已预授 {len(ok)}: {", ".join(ok) or "无"}')
|
|
86
|
+
if skipped:
|
|
87
|
+
report.append(f'- 跳过 {len(skipped)}(授予失败或需特殊签名): {", ".join(skipped)}')
|
|
88
|
+
if user_confirm:
|
|
89
|
+
report.append(f'- 需用户在设备上确认: {", ".join(user_confirm)}')
|
|
90
|
+
reminders.append(f'身份类权限需手动确认: {", ".join(user_confirm)}')
|
|
91
|
+
report.append('')
|
|
92
|
+
|
|
93
|
+
# ── 2. 登录策略(用户 2026-09-27 指正:不能止步于标 BLOCKED,必须提醒用户三选一)──
|
|
94
|
+
report.append('## 登录/账号策略')
|
|
95
|
+
if feats.get('has_login'):
|
|
96
|
+
acct = os.environ.get(args.test_account_env, '')
|
|
97
|
+
if acct:
|
|
98
|
+
report.append(f'- 检测到登录页;使用环境变量 {args.test_account_env} 提供的测试账号(自动登录用例将启用)')
|
|
99
|
+
else:
|
|
100
|
+
report.append('- 检测到登录页;**未设置测试账号(export '
|
|
101
|
+
f'{args.test_account_env}=user:pass)**。登录域用例暂标 BLOCKED,'
|
|
102
|
+
'**请从以下三种方式选择其一解除**:')
|
|
103
|
+
report.append(' 1. **手动登录**:在模拟器/真机上人工完成一次登录(登录态持久,最真实,'
|
|
104
|
+
'适合无需反复切换账号的场景)——完成后告知,BLOCKED 用例即可重跑;')
|
|
105
|
+
report.append(' 2. **提供账号密码**:给出测试账号,构造登录用例(输入账号密码→提交→断言登录成功),'
|
|
106
|
+
'登录链路本身也被覆盖——需 export 环境变量或直接告知;')
|
|
107
|
+
report.append(' 3. **mock 登录态**:修改代码伪造已登录状态(如 userName 直接赋值)——'
|
|
108
|
+
'**注意:这不是真实功能状态**,仅解锁被门禁的功能验证(hn-android FS-41 先例),'
|
|
109
|
+
'需在用例/代码注释标明,且登录链路本身仍属未覆盖。')
|
|
110
|
+
reminders.append('登录域用例 BLOCKED 待解除:手动登录设备 / 提供测试账号 / mock 登录态(三选一,见 preconditions.md)')
|
|
111
|
+
else:
|
|
112
|
+
report.append('- 未检测到登录页,无需账号')
|
|
113
|
+
report.append('')
|
|
114
|
+
|
|
115
|
+
# ── 3. 首启弹窗预处置 ──
|
|
116
|
+
report.append('## 首启弹窗预处置(自动 dismiss 片段,注入 _common.launch)')
|
|
117
|
+
report.append('```python')
|
|
118
|
+
report.append('# 常见首启弹窗一键处理(在 launch 后 sleep 1 时执行)')
|
|
119
|
+
report.append('for txt in ["同意并继续", "允许", "我知道了", "暂不", "以后再说"]:')
|
|
120
|
+
report.append(' try:')
|
|
121
|
+
report.append(' c = d.find_component(BY.text(txt))')
|
|
122
|
+
report.append(' if c: c.click(); time.sleep(0.8)')
|
|
123
|
+
report.append(' except Exception: pass')
|
|
124
|
+
report.append('```')
|
|
125
|
+
report.append('')
|
|
126
|
+
|
|
127
|
+
if reminders:
|
|
128
|
+
report += ['## ⚠️ 用户提醒清单', *[f'- {r}' for r in reminders], '']
|
|
129
|
+
else:
|
|
130
|
+
report.append('## 用户提醒清单: 无(全部前置已自动完成)')
|
|
131
|
+
|
|
132
|
+
out_md = os.path.join(os.path.dirname(os.path.abspath(args.features)),
|
|
133
|
+
'preconditions.md')
|
|
134
|
+
open(out_md, 'w', encoding='utf-8').write('\n'.join(report))
|
|
135
|
+
print('\n'.join(report))
|
|
136
|
+
print(f'\n报告: {out_md}')
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == '__main__':
|
|
140
|
+
main()
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""preinject_ids.py — ID 预置(铁律①的机械强制,先于脚本编写,不依赖 cases DSL)。
|
|
3
|
+
|
|
4
|
+
输入 features.json(scan_features 产物),对全部 has_id=False 的交互点批量注入
|
|
5
|
+
`.id('tc_<page>_<n>')`(资源文案/精确模糊文本/行号三路定位,属性链内插桩永远合法),
|
|
6
|
+
输出 ids.json 映射(脚本编写只认此表与既有 ID)。注入后须 rebuild + uninstall&&install。
|
|
7
|
+
|
|
8
|
+
用法:
|
|
9
|
+
python3 preinject_ids.py --features features.json [--out ids.json]
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import glob
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
TEXT_PAT = re.compile(
|
|
18
|
+
r"^(\s*)(Text|Button|Toggle|Checkbox|Radio|MenuItem|Image|Row|Column)\("
|
|
19
|
+
r"[^\n]*?['\"](.*?)['\"][^\n]*\)?\s*$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read(p):
|
|
23
|
+
return open(p, encoding='utf-8').read()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def insert_after_component(lines, i):
|
|
27
|
+
depth = 0
|
|
28
|
+
started = False
|
|
29
|
+
for k in range(i, min(len(lines), i + 40)):
|
|
30
|
+
depth += lines[k].count('(') - lines[k].count(')')
|
|
31
|
+
started = True
|
|
32
|
+
if started and depth <= 0 and k > i:
|
|
33
|
+
return k + 1
|
|
34
|
+
if started and depth < 0:
|
|
35
|
+
return k
|
|
36
|
+
return min(len(lines), i + 12)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_and_inject(page_file, target, seq):
|
|
40
|
+
"""三路定位注入:资源文案 → 精确/模糊文本 → 行号。返回 (id or None, modified_src)。"""
|
|
41
|
+
src = read(page_file)
|
|
42
|
+
lines = src.split('\n')
|
|
43
|
+
tid = f"tc_{os.path.splitext(os.path.basename(page_file))[0].lower()}_{seq}"
|
|
44
|
+
|
|
45
|
+
if target and re.search(rf"\.id\(\s*'{re.escape(target)}'", src):
|
|
46
|
+
return target, src
|
|
47
|
+
|
|
48
|
+
if target and 'app.string.' in src:
|
|
49
|
+
modroot = page_file
|
|
50
|
+
for _ in range(4):
|
|
51
|
+
modroot = os.path.dirname(modroot)
|
|
52
|
+
cand = os.path.join(modroot, 'resources', 'base', 'element', 'string.json')
|
|
53
|
+
if os.path.exists(cand):
|
|
54
|
+
resname = None
|
|
55
|
+
try:
|
|
56
|
+
for item in json.load(open(cand, encoding='utf-8')).get('string', []):
|
|
57
|
+
if item.get('value') == target:
|
|
58
|
+
resname = item.get('name')
|
|
59
|
+
break
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
if resname:
|
|
63
|
+
break
|
|
64
|
+
if resname:
|
|
65
|
+
rx = re.compile(r"[\$r]\(\s*'app\.string\." + re.escape(resname) + r"'")
|
|
66
|
+
for i, ln in enumerate(lines):
|
|
67
|
+
if rx.search(ln):
|
|
68
|
+
for k in range(i, min(len(lines), i + 10)):
|
|
69
|
+
if re.match(r'^\s*\.[a-zA-Z]', lines[k]):
|
|
70
|
+
indent = re.match(r'(\s*)', lines[k]).group(1)
|
|
71
|
+
lines.insert(k, f"{indent}.id('{tid}')")
|
|
72
|
+
return tid, '\n'.join(lines)
|
|
73
|
+
indent = re.match(r'(\s*)', ln).group(1)
|
|
74
|
+
lines.insert(i + 1, f"{indent} .id('{tid}')")
|
|
75
|
+
return tid, '\n'.join(lines)
|
|
76
|
+
|
|
77
|
+
if target:
|
|
78
|
+
for i, ln in enumerate(lines):
|
|
79
|
+
m = TEXT_PAT.match(ln)
|
|
80
|
+
if not m:
|
|
81
|
+
continue
|
|
82
|
+
if m.group(3) == target or (target in m.group(3) or m.group(3) in target):
|
|
83
|
+
seg = '\n'.join(lines[i:i + 25])
|
|
84
|
+
if re.search(r'\.id\(', seg):
|
|
85
|
+
idm = re.search(r"\.id\(\s*'([^']+)'", seg)
|
|
86
|
+
if idm:
|
|
87
|
+
return idm.group(1), src
|
|
88
|
+
for k in range(i + 1, min(len(lines), i + 15)):
|
|
89
|
+
if re.match(r'^\s*\.[a-zA-Z]', lines[k]):
|
|
90
|
+
indent = re.match(r'(\s*)', lines[k]).group(1)
|
|
91
|
+
lines.insert(k, f"{indent}.id('{tid}')")
|
|
92
|
+
return tid, '\n'.join(lines)
|
|
93
|
+
j = insert_after_component(lines, i)
|
|
94
|
+
lines.insert(j, f"{m.group(1)} .id('{tid}')")
|
|
95
|
+
return tid, '\n'.join(lines)
|
|
96
|
+
|
|
97
|
+
tc = re.match(r'line(\d+):(\w+)', target or '')
|
|
98
|
+
if tc:
|
|
99
|
+
i = int(tc.group(1)) - 1
|
|
100
|
+
if 0 <= i < len(lines):
|
|
101
|
+
for k in range(i + 1, min(len(lines), i + 9)):
|
|
102
|
+
if re.match(r'^\s*\.[a-zA-Z]', lines[k]):
|
|
103
|
+
indent = re.match(r'(\s*)', lines[k]).group(1)
|
|
104
|
+
lines.insert(k, f"{indent}.id('{tid}')")
|
|
105
|
+
return tid, '\n'.join(lines)
|
|
106
|
+
for k in range(i, max(0, i - 8), -1):
|
|
107
|
+
if re.match(r'^\s*\.[a-zA-Z]', lines[k]):
|
|
108
|
+
indent = re.match(r'(\s*)', lines[k]).group(1)
|
|
109
|
+
lines.insert(k + 1, f"{indent}.id('{tid}')")
|
|
110
|
+
return tid, '\n'.join(lines)
|
|
111
|
+
return None, src
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main():
|
|
115
|
+
ap = argparse.ArgumentParser()
|
|
116
|
+
ap.add_argument('--features', required=True)
|
|
117
|
+
ap.add_argument('--out', default=None)
|
|
118
|
+
args = ap.parse_args()
|
|
119
|
+
feats = json.load(open(args.features, encoding='utf-8'))
|
|
120
|
+
project = feats['project_dir']
|
|
121
|
+
out = args.out or os.path.join(os.path.dirname(args.features), 'ids.json')
|
|
122
|
+
mapping = {}
|
|
123
|
+
if os.path.exists(out):
|
|
124
|
+
try:
|
|
125
|
+
mapping = json.load(open(out, encoding='utf-8'))
|
|
126
|
+
except Exception:
|
|
127
|
+
mapping = {}
|
|
128
|
+
injected = existing = missed = 0
|
|
129
|
+
for pg in feats.get('pages', []):
|
|
130
|
+
fpath = pg.get('file')
|
|
131
|
+
if not fpath or not os.path.exists(fpath):
|
|
132
|
+
continue
|
|
133
|
+
seq = 0
|
|
134
|
+
for it in pg.get('interactions', []):
|
|
135
|
+
target = it.get('target')
|
|
136
|
+
desc = it.get('desc')
|
|
137
|
+
if not target:
|
|
138
|
+
continue
|
|
139
|
+
seq += 1
|
|
140
|
+
key = f"{pg['page']}:{desc or target}"
|
|
141
|
+
prev = mapping.get(key)
|
|
142
|
+
if prev and re.search(rf"\.id\(\s*'{re.escape(prev)}'", read(fpath)):
|
|
143
|
+
existing += 1
|
|
144
|
+
continue
|
|
145
|
+
if it.get('has_id'):
|
|
146
|
+
existing += 1
|
|
147
|
+
mapping[key] = target
|
|
148
|
+
continue
|
|
149
|
+
tid, new_src = find_and_inject(fpath, target, seq)
|
|
150
|
+
if not tid:
|
|
151
|
+
tid2, new_src2 = find_and_inject(fpath, f"line{it.get('line')}:{it.get('type')}", seq)
|
|
152
|
+
tid, new_src = tid2 or tid, new_src2 if tid2 else new_src
|
|
153
|
+
if tid:
|
|
154
|
+
disk = read(fpath)
|
|
155
|
+
if new_src != disk:
|
|
156
|
+
open(fpath, 'w', encoding='utf-8').write(new_src) # 逐次落盘(页尾统一写会丢同页其余注入)
|
|
157
|
+
injected += 1
|
|
158
|
+
else:
|
|
159
|
+
existing += 1
|
|
160
|
+
mapping[key] = tid
|
|
161
|
+
else:
|
|
162
|
+
missed += 1
|
|
163
|
+
for res_id in re.findall(r"\.id\('([a-z][a-z0-9_]+)'\)", '\n'.join(
|
|
164
|
+
read(p) for p in glob.glob(os.path.join(project, '**', '*.ets'), recursive=True)
|
|
165
|
+
if 'oh_modules' not in p))[:0]:
|
|
166
|
+
pass
|
|
167
|
+
# 连续 .id() 消除(TD-006):注入点组件已有语义 id 时,删除本轮新插的机械 id,
|
|
168
|
+
# mapping 改指向已有 id——运行时链式 .id() 后者覆盖前者,机械 id 失效且污染源码
|
|
169
|
+
import glob as _g
|
|
170
|
+
fixed = 0
|
|
171
|
+
for pg in feats.get('pages', []):
|
|
172
|
+
fp = pg.get('file')
|
|
173
|
+
if not fp or not os.path.exists(fp):
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
src = open(fp, encoding='utf-8').read()
|
|
177
|
+
except Exception:
|
|
178
|
+
continue
|
|
179
|
+
new_src = src
|
|
180
|
+
for m in re.finditer(r"\.id\('(tc_[^']+)'\)\s*\.id\('([^']+)'\)", src):
|
|
181
|
+
dead, alive = m.group(1), m.group(2)
|
|
182
|
+
new_src = new_src.replace(f".id('{dead}')\n", '', 1).replace(f".id('{dead}')", '', 1)
|
|
183
|
+
for k, v in list(mapping.items()):
|
|
184
|
+
if v == dead:
|
|
185
|
+
mapping[k] = alive
|
|
186
|
+
fixed += 1
|
|
187
|
+
if new_src != src:
|
|
188
|
+
open(fp, 'w', encoding='utf-8').write(new_src)
|
|
189
|
+
# ID 稳定性门禁(TD-013):旧 ids.json 被用例引用的 id 若在新轮消失 → BLOCKER 阻断,
|
|
190
|
+
# 禁止静默让用例回退 text 定位(run 期间 id 漂移的机制化防线)
|
|
191
|
+
old_out = out + '.prev'
|
|
192
|
+
if os.path.exists(out):
|
|
193
|
+
import shutil as _sh
|
|
194
|
+
_sh.copy(out, old_out)
|
|
195
|
+
try:
|
|
196
|
+
old_map = json.load(open(old_out, encoding='utf-8'))
|
|
197
|
+
except Exception:
|
|
198
|
+
old_map = {}
|
|
199
|
+
gone = {k: v for k, v in old_map.items() if v not in set(mapping.values())
|
|
200
|
+
and v not in {m for m in mapping.values()}}
|
|
201
|
+
if gone:
|
|
202
|
+
print(f'✗ BLOCKER:上一轮 {len(gone)} 个 id 本轮消失(用例可能引用,禁静默回退 text):')
|
|
203
|
+
for k, v in list(gone.items())[:6]:
|
|
204
|
+
print(f' {k} → {v}')
|
|
205
|
+
print(' 处置:核对注入冲突/页面改动,或显式重命名并同步全部用例后重跑。')
|
|
206
|
+
json.dump(mapping, open(out, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
|
|
207
|
+
print(f'ID 预置完成: 新注入 {injected} / 已有 {existing} / 未定位 {missed}'
|
|
208
|
+
f' / 连续id消除 {fixed} → {out}')
|
|
209
|
+
|
|
210
|
+
# 语法快速自检:检测连续 .id() 调用(最常见注入冲突)
|
|
211
|
+
import re as _re
|
|
212
|
+
_warn = 0
|
|
213
|
+
for pg in feats.get('pages', []):
|
|
214
|
+
fp = pg.get('file')
|
|
215
|
+
if not fp or not os.path.exists(fp):
|
|
216
|
+
continue
|
|
217
|
+
try:
|
|
218
|
+
src = open(fp, encoding='utf-8').read()
|
|
219
|
+
for m in _re.finditer(r"\.id\(\s*'[^']+'\s*\)\s*\.id\(", src):
|
|
220
|
+
print(f' ⚠️ 语法风险: {os.path.basename(fp)} 存在连续 .id() 调用')
|
|
221
|
+
_warn += 1
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
if _warn:
|
|
225
|
+
print(f'⚠️ 发现 {_warn} 处语法风险——建议先跑构建验证再使用 ids.json')
|
|
226
|
+
else:
|
|
227
|
+
print('✓ 语法快速检查通过')
|
|
228
|
+
print('⚠️ 注入后必须 rebuild + uninstall&&install 再执行脚本')
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == '__main__':
|
|
232
|
+
main()
|