@harmonyos-arkts/d2h 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/README.md +19 -18
- package/agents/android-to-hmos-00-orchestrator.md +1 -1
- package/agents/d2h.md +101 -285
- package/dist/bin/install-opencode.js +7 -2
- package/dist/src/install-opencode.js +20 -1
- package/dist/src/install-vscode.js +6 -3
- package/dist/src/metrics-plugin.js +20 -0
- package/package.json +2 -1
- package/scripts/check-environment.mjs +1 -2
- package/skills/d2h-hypium-forge/SKILL.md +59 -2
- package/skills/d2h-hypium-forge/scripts/_common_template.py +85 -40
- package/skills/d2h-hypium-forge/scripts/analyze_results.py +11 -2
- package/skills/d2h-hypium-forge/scripts/probe.py +197 -0
- package/skills/d2h-hypium-forge/scripts/run_direct.py +464 -33
- package/skills/d2h-page-develop/SKILL.md +112 -0
- package/skills/d2h-page-develop/agents/openai.yaml +7 -0
- package/skills/d2h-page-spec-generate/SKILL.md +206 -0
- package/skills/d2h-page-spec-generate/scripts/build_batch_report.py +227 -0
- package/skills/managing-d2h-toolchain/SKILL.md +14 -13
- package/skills/managing-d2h-toolchain/scripts/manage-toolchain.mjs +98 -0
- package/skills/appgraph-rule-audit/SKILL.md +0 -77
- package/skills/appgraph-rule-audit/references/audit-contract.md +0 -47
- package/skills/appgraph-rule-audit/references/recommendation-schema.md +0 -41
- package/skills/appgraph-rule-audit/schemas/rule-opportunities.schema.json +0 -125
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import { updateMetricsPlugin } from './metrics-plugin.js';
|
|
5
6
|
function stat(file) {
|
|
6
7
|
try {
|
|
7
8
|
return fs.lstatSync(file);
|
|
@@ -68,7 +69,7 @@ function resolvedLocation(directory) {
|
|
|
68
69
|
const parent = path.dirname(directory);
|
|
69
70
|
return parent === directory ? directory : path.join(resolvedLocation(parent), path.basename(directory));
|
|
70
71
|
}
|
|
71
|
-
export function installOpenCode({ sourceDir, configDir, agent = 'opencode', link = false, force = false, dryRun = false, log = console.log }) {
|
|
72
|
+
export function installOpenCode({ sourceDir, configDir, agent = 'opencode', link = false, force = false, dryRun = false, remote = false, log = console.log }) {
|
|
72
73
|
if (!['opencode', 'deveco'].includes(agent))
|
|
73
74
|
throw new Error(`unsupported agent: ${agent}`);
|
|
74
75
|
const application = agent === 'deveco' ? 'DevEco Code' : 'OpenCode';
|
|
@@ -80,6 +81,13 @@ export function installOpenCode({ sourceDir, configDir, agent = 'opencode', link
|
|
|
80
81
|
contains(sourceRoot, configRoot) || contains(configRoot, sourceRoot)) {
|
|
81
82
|
throw new Error(`refusing unsafe OpenCode config directory: ${configDir}`);
|
|
82
83
|
}
|
|
84
|
+
const configFile = path.join(configRoot, 'opencode.json');
|
|
85
|
+
const existingConfig = stat(configFile);
|
|
86
|
+
if (existingConfig?.isSymbolicLink())
|
|
87
|
+
throw new Error(`refusing linked OpenCode config file: ${configFile}`);
|
|
88
|
+
const config = existingConfig ? JSON.parse(fs.readFileSync(configFile, 'utf8')) : {};
|
|
89
|
+
updateMetricsPlugin(config, remote);
|
|
90
|
+
const configText = JSON.stringify(config, null, 2) + '\n';
|
|
83
91
|
const entries = [];
|
|
84
92
|
for (const kind of ['skills', 'agents']) {
|
|
85
93
|
const source = path.join(sourceRoot, kind);
|
|
@@ -157,6 +165,17 @@ export function installOpenCode({ sourceDir, configDir, agent = 'opencode', link
|
|
|
157
165
|
const skills = entries.filter(entry => entry.kind === 'skills').length;
|
|
158
166
|
const agents = entries.filter(entry => entry.kind === 'agents').length;
|
|
159
167
|
if (!dryRun) {
|
|
168
|
+
if (!existingConfig || fs.readFileSync(configFile, 'utf8') !== configText) {
|
|
169
|
+
const temporaryConfig = `${configFile}.d2h-${process.pid}.tmp`;
|
|
170
|
+
try {
|
|
171
|
+
fs.writeFileSync(temporaryConfig, configText);
|
|
172
|
+
fs.renameSync(temporaryConfig, configFile);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
if (fs.existsSync(temporaryConfig))
|
|
176
|
+
fs.rmSync(temporaryConfig);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
160
179
|
const receipt = path.join(configRoot, '.d2h-installation.json');
|
|
161
180
|
const temporary = path.join(configRoot, `.d2h-installation-${process.pid}.tmp`);
|
|
162
181
|
const packageFile = path.join(sourceRoot, 'package.json');
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import { updateMetricsPlugin } from './metrics-plugin.js';
|
|
5
6
|
const extensionPrefix = 'huaweideveloperserviceplatform.harmonyosdevassistantforvscode-';
|
|
6
7
|
const modeLabel = '安卓转鸿蒙';
|
|
7
8
|
function digest(file) {
|
|
@@ -72,7 +73,7 @@ export function updateD2HModeLabel(source) {
|
|
|
72
73
|
: original.slice(0, -1) + `,d2h:${JSON.stringify(modeLabel)}}`;
|
|
73
74
|
return source.slice(0, matches[0].index) + next + source.slice(matches[0].index + original.length);
|
|
74
75
|
}
|
|
75
|
-
function updateConfig(source) {
|
|
76
|
+
function updateConfig(source, remote) {
|
|
76
77
|
const value = JSON.parse(source);
|
|
77
78
|
value.agent ??= {};
|
|
78
79
|
value.agent.d2h = { ...value.agent.d2h, description: modeLabel,
|
|
@@ -84,6 +85,8 @@ function updateConfig(source) {
|
|
|
84
85
|
throw new Error('opencode plugin list must be an array');
|
|
85
86
|
if (!value.plugin.includes('./plugin/d2h'))
|
|
86
87
|
value.plugin.push('./plugin/d2h');
|
|
88
|
+
if (remote !== undefined)
|
|
89
|
+
updateMetricsPlugin(value, remote);
|
|
87
90
|
return JSON.stringify(value, null, 2) + '\n';
|
|
88
91
|
}
|
|
89
92
|
function copyWithBackup(source, destination, backupRoot, root) {
|
|
@@ -111,7 +114,7 @@ function copyWithBackup(source, destination, backupRoot, root) {
|
|
|
111
114
|
}
|
|
112
115
|
return true;
|
|
113
116
|
}
|
|
114
|
-
export function installVSCode({ sourceDir, extensionDir, bundleCacheRoot, dryRun = false, log = console.log }) {
|
|
117
|
+
export function installVSCode({ sourceDir, extensionDir, bundleCacheRoot, remote = false, dryRun = false, log = console.log }) {
|
|
115
118
|
const sourceRoot = fs.realpathSync(sourceDir);
|
|
116
119
|
const root = validatedRoot(locateVSCodeExtension(extensionDir));
|
|
117
120
|
const bundle = activeBundle(root, bundleCacheRoot);
|
|
@@ -120,7 +123,7 @@ export function installVSCode({ sourceDir, extensionDir, bundleCacheRoot, dryRun
|
|
|
120
123
|
const bundleConfigFile = path.join(bundle, 'opencode.json');
|
|
121
124
|
const distFiles = ['dist/extension.js', 'dist/webview/main.js'];
|
|
122
125
|
const patches = [
|
|
123
|
-
{ file: configFile, content: updateConfig(fs.readFileSync(configFile, 'utf8')) },
|
|
126
|
+
{ file: configFile, content: updateConfig(fs.readFileSync(configFile, 'utf8'), remote) },
|
|
124
127
|
...distFiles.map(name => ({ file: path.join(root, name), content: updateD2HModeLabel(fs.readFileSync(path.join(root, name), 'utf8')) })),
|
|
125
128
|
];
|
|
126
129
|
const copies = [
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const liteMetricsPlugin = 'opencode-metrics-plugin-lite';
|
|
2
|
+
export const remoteMetricsPlugin = 'opencode-metrics-plugin';
|
|
3
|
+
export function updateMetricsPlugin(config, remote = false) {
|
|
4
|
+
const plugins = config.plugin ?? [];
|
|
5
|
+
if (!Array.isArray(plugins))
|
|
6
|
+
throw new Error('opencode plugin list must be an array');
|
|
7
|
+
const selected = remote ? remoteMetricsPlugin : liteMetricsPlugin;
|
|
8
|
+
const isMetrics = (entry) => {
|
|
9
|
+
const name = Array.isArray(entry) ? entry[0] : entry;
|
|
10
|
+
return name === liteMetricsPlugin || name === remoteMetricsPlugin;
|
|
11
|
+
};
|
|
12
|
+
const existing = plugins.findIndex(isMetrics);
|
|
13
|
+
const filtered = plugins.filter((entry, index) => !isMetrics(entry) || index === existing);
|
|
14
|
+
const selectedEntry = [selected, { agents: ['d2h'] }];
|
|
15
|
+
if (existing < 0)
|
|
16
|
+
filtered.push(selectedEntry);
|
|
17
|
+
else
|
|
18
|
+
filtered[filtered.findIndex(isMetrics)] = selectedEntry;
|
|
19
|
+
config.plugin = filtered;
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harmonyos-arkts/d2h",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Install Android-to-HarmonyOS migration agents and skills into OpenCode or DevEco Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"dist/src/",
|
|
12
12
|
"skills/",
|
|
13
13
|
"agents/",
|
|
14
|
+
"AGENTS.md",
|
|
14
15
|
"schemas/",
|
|
15
16
|
"opencode/",
|
|
16
17
|
".claude-plugin/",
|
|
@@ -7,7 +7,6 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
7
7
|
import { parseArgs } from 'node:util';
|
|
8
8
|
|
|
9
9
|
const DEFAULT_REQUIREMENTS = fileURLToPath(new URL('../skills/managing-d2h-toolchain/environment-requirements.json', import.meta.url));
|
|
10
|
-
const D2H_VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
11
10
|
const VERSION_TOOLS = ['node', 'python', 'git', 'npm', 'java', 'python-json5', 'appgraph', 'droid2hmos', 'hvigor', 'ohpm', 'devecocli'];
|
|
12
11
|
|
|
13
12
|
function versionParts(value) {
|
|
@@ -220,7 +219,7 @@ export async function collect(options, { runner = probe, env = process.env, plat
|
|
|
220
219
|
}
|
|
221
220
|
}
|
|
222
221
|
for (const name of ['appgraph', 'droid2hmos']) {
|
|
223
|
-
await tool(name, name, ['--version'],
|
|
222
|
+
await tool(name, name, ['--version'], '运行 managing-d2h-toolchain Skill 中的 node scripts/manage-toolchain.mjs install 安装精确版本;本地 npm link 需由开发者显式管理。');
|
|
224
223
|
}
|
|
225
224
|
const deveco = directoryFinder([env.DEVECO_STUDIO_HOME, env.DEVECO_HOME,
|
|
226
225
|
platform === 'darwin' ? '/Applications/DevEco-Studio.app/Contents' : null,
|
|
@@ -177,12 +177,69 @@ first_launch** 分组排序执行(破坏性递增,bm clean 类强制排尾
|
|
|
177
177
|
完整 `ensure_login`,组内后续幂等复用。组首执行时 `FORGE_GROUP_FIRST=1`。
|
|
178
178
|
results.json 每条附 `group` 字段可分组统计。**禁把 reset_data=True 用在 logged_in 组**。
|
|
179
179
|
|
|
180
|
-
### 5. 裸跑执行 + 四簇分析 + HTML
|
|
180
|
+
### 5. 裸跑执行 + 四簇分析 + HTML 报告(重跑阶梯 = 硬规范)
|
|
181
|
+
|
|
182
|
+
**执行命令阶梯(--all 仅限首轮与门禁升级,修复轮禁无脑全量;违反会被 runner 硬阻断)**:
|
|
183
|
+
> TD-LADDER v3 防护(已内置于 run_direct.py):**所有"显式全量"(`--all` / `--acceptance` / 变相全量的 `--filter`)统一受管**,
|
|
184
|
+
> 不再给 `--acceptance` 开后门。五条规则(任一命中即 exit 2 阻断并输出指引):
|
|
185
|
+
> ① **R1** 上轮 results.json 存在 FAIL/SKIP → 必须先修(`--case`/`--retry-failed`),禁止全量;
|
|
186
|
+
> ② **R2** 源码 `srcHash` 与上次全量相同 → 重复全量无新结论(仅拦 `--all`);
|
|
187
|
+
> ③ **R2b** 上次已是**同源验收**(`lastMode=acceptance` 且 srcHash 未变)→ 拒绝重复验收;
|
|
188
|
+
> ④ **R3** 本修复周期显式全量已达上限(默认 2,`FORGE_FULL_BUDGET` 可调)→ 归零条件是"跑出全绿";
|
|
189
|
+
> ⑤ **L1** `--filter` 覆盖 >50% 用例 = 变相全量,按全量纳管;**L2** 连续两轮失败签名一致且源码未变 → 拒绝重跑(先 probe 定案)。
|
|
190
|
+
> 免检:`--retry-failed` 因源码变更**自动升级**的全量(非显式,不占预算);`--force-all` 为显式逃生门。
|
|
191
|
+
> droid2hmos CLI 的 `test harmony --runner python --all` 同步受 R1 防护(`--force-all` 放行)。
|
|
192
|
+
> 实证(Grit):修复期 15 次全量 212min,其中 **`--acceptance` 被当日常全量跑 13 次 157min(占测试时间 65%)**;
|
|
193
|
+
> `--filter TC0` 当"小全量"跑 7 次;同族失败连卡 5 轮。v3 的 R1 覆盖 / R3 计次 / L1 / R2b / L2 即由此固化。
|
|
194
|
+
> 合法全量收敛为:首轮(无历史)+ 终局验收(全绿后一次)+ 逃生门。
|
|
181
195
|
```bash
|
|
182
|
-
|
|
196
|
+
# 首轮全量(唯一常规使用 --all 的场景)
|
|
197
|
+
python3 $F/run_direct.py --all --dir testcases --device <sn>
|
|
198
|
+
|
|
199
|
+
# 修复轮阶梯(Grit 实证:15 次全量 212min,其中 5 轮卡同一 bug 空转 71min)
|
|
200
|
+
python3 $F/run_direct.py --case <失败用例> --dir testcases --device <sn> # ① 修测试脚本后单条验证(1-4min)
|
|
201
|
+
python3 $F/run_direct.py --retry-failed --dir testcases --device <sn> # ② 修产品代码后增量重跑(srcHash 门禁自动升级全量)
|
|
202
|
+
python3 $F/run_direct.py --filter TC3 --dir testcases --device <sn> # ③ 同族用例子集回归(如 TC30/31/35)
|
|
203
|
+
|
|
183
204
|
python3 $F/analyze_results.py --results results.json --runtime-pages runtime_pages.json
|
|
184
205
|
python3 $F/forge_report.py # 产出 test-report.html(Allure 风格:概况/用例卡片/关键截图内嵌)
|
|
185
206
|
```
|
|
207
|
+
铁律:修 1 个用例 ≠ 重跑 24 个。run_direct 汇总后 FAIL>0 会直接打印下一步命令——照抄执行。
|
|
208
|
+
|
|
209
|
+
**范围回归(touched,产品代码变更后的默认门禁策略)**:
|
|
210
|
+
`--retry-failed` 检测到产品代码变更时不再一律全量——按 `results.meta.json` 的**逐文件指纹**
|
|
211
|
+
diff 出改动集,若全部是 ids.json 可定位的 UI 文件,则只重跑 `FAIL/SKIP + 引用这些文件的用例`
|
|
212
|
+
(用例源码中的 id 字面量 → ids.json 文件映射)。改动含共享逻辑(viewmodel/repository)或
|
|
213
|
+
范围超 60% → 自动退化全量。禁手动 `--all` 替代;`--regression-scope full` 仅用于里程碑。
|
|
214
|
+
|
|
215
|
+
**热启动(组内软复用)**:组内非首条用例 `FORGE_WARM_LAUNCH=1`,`launch()` 探测 HOME_ANCHOR
|
|
216
|
+
已在位即跳过 stop/start/bootstrap(省 8-12s/例);组首条与 first_launch 组保持冷启;
|
|
217
|
+
`--case` 单条验证默认热启动(`--cold` 强制冷启)。断言冷启行为的用例写 `launch(d, force=True)`。
|
|
218
|
+
单用例挂死由看门狗截断(`--case-timeout 240` 默认,超时记 FAIL 继续下一条)。
|
|
219
|
+
瞬态失败(watchdog/连接类)轮末自动复测一次(`--flake-retry 1` 默认,断言类失败不
|
|
220
|
+
自动复测防掩盖真缺陷)。**零进展检测**:失败签名集与上轮完全一致时 runner 直接亮牌
|
|
221
|
+
警告——停止重跑转 probe 定案(Grit 实证:同族失败连卡 5 轮全量空转 71min)。
|
|
222
|
+
调试迭代期可 `FORGE_WAIT_SCALE=0.5` 把断言超时减半加速失败复现;正式回归保持 1。
|
|
223
|
+
|
|
224
|
+
**最终质量门(不可跳过)**:所有优化(范围回归/热启动/flake 复测)只加速迭代期;
|
|
225
|
+
**交付/收尾前必须执行一次验收全量**:`run_direct.py --acceptance`(强制 --all + 全程截图 +
|
|
226
|
+
`.acceptance` 标记永久保护,最近 2 次留存),FAIL=0 或全部 FAIL 已登记缺陷并经 probe 定案。
|
|
227
|
+
范围回归的 PASS 保留记录不作为最终结论——它只是迭代加速器,最终覆盖度以这次验收全量为准。
|
|
228
|
+
|
|
229
|
+
**截图与 reports/ 留存(防体积失控——Grit 实证 5266 张/666MB)**:
|
|
230
|
+
- 迭代期 `--shots fail`(默认):PASS 用例只留 `<case>_end.jpeg`,过程帧/分步图仅 FAIL 用例保留;
|
|
231
|
+
连续相同帧自动去重(静止页 8 帧→1 帧)。
|
|
232
|
+
- **中间运行目录即用即弃**(`--keep-reports` 默认 0):每轮结束只保留本轮供当轮取证,
|
|
233
|
+
下一轮自动回收上一轮中间目录;需要跨轮对照时 `--keep-reports 3`。
|
|
234
|
+
- **验收全程留存**:`--acceptance`(最终质量门专用)强制全量+全程截图,目录写 `.acceptance`
|
|
235
|
+
标记,prune 独立保护**最近 2 次**验收目录,永久作为交付证据。
|
|
236
|
+
**取证一条命令(probe.py,禁手写 UiDriver 内联脚本——Grit 实证 69 次手写探针多花一半时间在代码生成上)**:
|
|
237
|
+
```bash
|
|
238
|
+
python3 $F/probe.py texts|ids|find <id>|tree --grep x # 单次 dumpLayout 本地解析取证
|
|
239
|
+
python3 $F/probe.py click <id|text> # uinput 坐标点击(Toggle 等 touch 失效场景可靠)
|
|
240
|
+
python3 $F/probe.py shot / hilog JSAPP 400 / db-pull <pkg> / db-sql <db> '<sql>'
|
|
241
|
+
```
|
|
242
|
+
|
|
186
243
|
失败四簇:DEVICE(环境,重跑)/ DESIGN-ERROR(脚本定位/步骤错误,修脚本 ≤2 轮)/
|
|
187
244
|
NAV-CONTEXT-LOSS(页面上下文丢失,hilog 定案)/ ASSERT-FAIL(**优先怀疑产品缺陷,保留 FAIL**)。
|
|
188
245
|
|
|
@@ -11,10 +11,23 @@
|
|
|
11
11
|
UI 登录——组首一次完整 setup,组内后续直接复用(省重复登录/注册)。
|
|
12
12
|
组首条执行时环境变量 FORGE_GROUP_FIRST=1(可选做完整冷启验证)。
|
|
13
13
|
"""
|
|
14
|
+
import os as _os
|
|
14
15
|
import time
|
|
15
16
|
from hypium import *
|
|
16
17
|
from hypium.model import MatchPattern, UiParam
|
|
17
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
|
+
|
|
18
31
|
PACKAGE = "me.hackerchick.raisetoanswer"
|
|
19
32
|
ABILITY = "EntryAbility"
|
|
20
33
|
ENTRY_PAGE = "pages/Index"
|
|
@@ -37,6 +50,21 @@ def _page_texts(d):
|
|
|
37
50
|
except Exception:
|
|
38
51
|
return []
|
|
39
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
|
+
|
|
40
68
|
def bootstrap(d, gates=None, home_tab="", anchor_key="", total=60, log=print):
|
|
41
69
|
"""冷启到可用基座。流程:轮询消化全部弹窗关卡(一轮多点,直到无特征)→
|
|
42
70
|
tab 归位(点目标 tab + 验证锚)→ 主页锚可见返回 True。
|
|
@@ -55,19 +83,18 @@ def bootstrap(d, gates=None, home_tab="", anchor_key="", total=60, log=print):
|
|
|
55
83
|
return True
|
|
56
84
|
except Exception:
|
|
57
85
|
pass
|
|
58
|
-
# 2)
|
|
86
|
+
# 2) 消化所有弹窗关卡:一轮拉全可见文案(单 RPC 扫描),本地匹配命中再点击
|
|
59
87
|
hit_any = False
|
|
88
|
+
vis = set(_visible_clickables(d))
|
|
60
89
|
for t in all_texts:
|
|
61
|
-
if t == (home_tab or ""):
|
|
90
|
+
if t == (home_tab or "") or t not in vis:
|
|
62
91
|
continue
|
|
63
92
|
try:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
time.sleep(1.2)
|
|
70
|
-
break # 点一个后重新扫(弹窗可能叠加/消失后露下一个)
|
|
93
|
+
d.touch(BY.text(t))
|
|
94
|
+
dismissed.append(t)
|
|
95
|
+
hit_any = True
|
|
96
|
+
time.sleep(1.2)
|
|
97
|
+
break # 点一个后重新扫(弹窗可能叠加/消失后露下一个)
|
|
71
98
|
except Exception:
|
|
72
99
|
pass
|
|
73
100
|
if hit_any:
|
|
@@ -125,10 +152,21 @@ def ensure_home_tab(d, tab_text, timeout=10):
|
|
|
125
152
|
HOME_TAB = "" # 应用级:主功能 tab 文案(如 "玩安卓");空串跳过归位
|
|
126
153
|
|
|
127
154
|
|
|
128
|
-
def launch(d, want="", reset_data=False):
|
|
155
|
+
def launch(d, want="", reset_data=False, force=False):
|
|
129
156
|
"""v3-①②(EXEC-034):want=启动参数('k=v;k2=v2' → hdc aa start --ps 透传,启动参数门控应用免人工补丁);
|
|
130
|
-
reset_data=True → bm clean -n
|
|
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/例);锚不可见自动回退完整冷启。"""
|
|
131
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
|
|
132
170
|
d.stop_app(PACKAGE)
|
|
133
171
|
time.sleep(0.8)
|
|
134
172
|
if reset_data:
|
|
@@ -180,18 +218,22 @@ def launch(d, want="", reset_data=False):
|
|
|
180
218
|
ok = bootstrap(d, home_tab=HOME_TAB, anchor_key=HOME_ANCHOR)
|
|
181
219
|
if not ok:
|
|
182
220
|
raise AssertionError("L0 bootstrap failed(弹窗/tab 未消化,见日志快照)")
|
|
183
|
-
time.sleep(
|
|
221
|
+
time.sleep(1.5) # bootstrap 锚已验证就位,1.5s 落座余量(原 2.5s)
|
|
184
222
|
_dismiss_first_run(d)
|
|
185
223
|
|
|
186
224
|
|
|
187
225
|
def _dismiss_first_run(d):
|
|
188
|
-
"""
|
|
226
|
+
"""首启弹窗一键处置(权限/隐私协议)——单 RPC 拉全文案本地匹配"""
|
|
227
|
+
vis = set(_visible_clickables(d))
|
|
189
228
|
for txt in ["同意并继续", "允许", "我知道了", "暂不", "以后再说", "同意"]:
|
|
229
|
+
if txt not in vis:
|
|
230
|
+
continue
|
|
190
231
|
try:
|
|
191
232
|
c = d.find_component(BY.text(txt))
|
|
192
233
|
if c:
|
|
193
234
|
c.click()
|
|
194
235
|
time.sleep(0.8)
|
|
236
|
+
vis = set(_visible_clickables(d)) # 点掉一层后重扫(可能叠弹窗)
|
|
195
237
|
except Exception:
|
|
196
238
|
pass
|
|
197
239
|
|
|
@@ -234,7 +276,7 @@ def click_tab(d, label):
|
|
|
234
276
|
return
|
|
235
277
|
except Exception:
|
|
236
278
|
pass
|
|
237
|
-
_t.sleep(0.
|
|
279
|
+
_t.sleep(0.25)
|
|
238
280
|
idx = TAB_LABELS.index(label) if label in TAB_LABELS else 2
|
|
239
281
|
w, h = d.get_display_size()
|
|
240
282
|
d.touch((int(w * (idx + 0.5) / len(TAB_LABELS)), int(h * 0.945)))
|
|
@@ -253,7 +295,7 @@ def click_text(d, txt, wait=6):
|
|
|
253
295
|
return
|
|
254
296
|
except Exception:
|
|
255
297
|
pass
|
|
256
|
-
deadline = _t.time() + wait
|
|
298
|
+
deadline = _t.time() + _w(wait)
|
|
257
299
|
while _t.time() < deadline:
|
|
258
300
|
try:
|
|
259
301
|
c = d.find_component(BY.text(txt, MatchPattern.CONTAINS))
|
|
@@ -263,14 +305,14 @@ def click_text(d, txt, wait=6):
|
|
|
263
305
|
return
|
|
264
306
|
except Exception:
|
|
265
307
|
pass
|
|
266
|
-
_t.sleep(0.
|
|
308
|
+
_t.sleep(0.25)
|
|
267
309
|
raise AssertionError("text not clickable: " + txt)
|
|
268
310
|
|
|
269
311
|
|
|
270
312
|
def click_type_first(d, ctype, wait=6):
|
|
271
313
|
"""点击指定类型的第一个组件(lineN:Type 引用的兜底点击)"""
|
|
272
314
|
import time as _t
|
|
273
|
-
deadline = _t.time() + wait
|
|
315
|
+
deadline = _t.time() + _w(wait)
|
|
274
316
|
while _t.time() < deadline:
|
|
275
317
|
try:
|
|
276
318
|
comps = d.find_all_components(BY.type(ctype))
|
|
@@ -281,7 +323,7 @@ def click_type_first(d, ctype, wait=6):
|
|
|
281
323
|
return
|
|
282
324
|
except Exception:
|
|
283
325
|
pass
|
|
284
|
-
_t.sleep(0.
|
|
326
|
+
_t.sleep(0.25)
|
|
285
327
|
raise AssertionError("type not clickable: " + ctype)
|
|
286
328
|
|
|
287
329
|
|
|
@@ -299,7 +341,7 @@ def id_input(d, cid, text, wait=6):
|
|
|
299
341
|
|
|
300
342
|
def _find(d, cid, wait=6):
|
|
301
343
|
import time as _t
|
|
302
|
-
deadline = _t.time() + wait
|
|
344
|
+
deadline = _t.time() + _w(wait)
|
|
303
345
|
while _t.time() < deadline:
|
|
304
346
|
try:
|
|
305
347
|
c = d.find_component(BY.key(cid))
|
|
@@ -313,14 +355,14 @@ def _find(d, cid, wait=6):
|
|
|
313
355
|
return c
|
|
314
356
|
except Exception:
|
|
315
357
|
pass
|
|
316
|
-
_t.sleep(0.
|
|
358
|
+
_t.sleep(0.25)
|
|
317
359
|
raise AssertionError("component not found: " + cid)
|
|
318
360
|
|
|
319
361
|
|
|
320
362
|
def smart_click(d, cid, txt=None, wait=6):
|
|
321
363
|
"""key 优先点击;失败且有 txt 时文本点击(mine 类 HAR 模块 ID 丢失边界)"""
|
|
322
364
|
import time as _t
|
|
323
|
-
deadline = _t.time() + wait
|
|
365
|
+
deadline = _t.time() + _w(wait)
|
|
324
366
|
while _t.time() < deadline:
|
|
325
367
|
try:
|
|
326
368
|
c = d.find_component(BY.key(cid))
|
|
@@ -330,7 +372,7 @@ def smart_click(d, cid, txt=None, wait=6):
|
|
|
330
372
|
return
|
|
331
373
|
except Exception:
|
|
332
374
|
pass
|
|
333
|
-
_t.sleep(0.
|
|
375
|
+
_t.sleep(0.25)
|
|
334
376
|
if txt and txt.startswith("line"):
|
|
335
377
|
import re as _re
|
|
336
378
|
m = _re.match(r"line\d+:(\w+)", txt)
|
|
@@ -365,7 +407,7 @@ def scroll_text_exist(d, txt, wait=6):
|
|
|
365
407
|
def smart_check(d, cid, txt=None, wait=6):
|
|
366
408
|
"""key 优先断言;失败且有 txt 时降级文本断言(部分 HAR 模块 .id 运行时丢弃的边界)"""
|
|
367
409
|
import time as _t
|
|
368
|
-
deadline = _t.time() + wait
|
|
410
|
+
deadline = _t.time() + _w(wait)
|
|
369
411
|
while _t.time() < deadline:
|
|
370
412
|
try:
|
|
371
413
|
c = d.find_component(BY.key(cid))
|
|
@@ -373,7 +415,7 @@ def smart_check(d, cid, txt=None, wait=6):
|
|
|
373
415
|
return True
|
|
374
416
|
except Exception:
|
|
375
417
|
pass
|
|
376
|
-
_t.sleep(0.
|
|
418
|
+
_t.sleep(0.25)
|
|
377
419
|
if txt and txt.startswith("line"):
|
|
378
420
|
import re as _re
|
|
379
421
|
m = _re.match(r"line\d+:(\w+)", txt)
|
|
@@ -389,7 +431,7 @@ def text_equals(d, txt, wait=6):
|
|
|
389
431
|
"""精确文本断言(EQUALS)——CONTAINS 会被前缀污染(如 ❄️Gallery 含 Gallery),
|
|
390
432
|
恢复/状态标记类断言必须用本原语。存在且精确相等返回 True,否则抛。"""
|
|
391
433
|
import time as _t
|
|
392
|
-
deadline = _t.time() + wait
|
|
434
|
+
deadline = _t.time() + _w(wait)
|
|
393
435
|
while _t.time() < deadline:
|
|
394
436
|
try:
|
|
395
437
|
c = d.find_component(BY.text(txt))
|
|
@@ -397,7 +439,7 @@ def text_equals(d, txt, wait=6):
|
|
|
397
439
|
return True
|
|
398
440
|
except Exception:
|
|
399
441
|
pass
|
|
400
|
-
_t.sleep(0.
|
|
442
|
+
_t.sleep(0.25)
|
|
401
443
|
raise AssertionError("text (exact) not found: " + txt)
|
|
402
444
|
|
|
403
445
|
|
|
@@ -418,8 +460,11 @@ def text_absent(d, txt, wait=3):
|
|
|
418
460
|
|
|
419
461
|
def step_shot(d, label="step"):
|
|
420
462
|
"""分步截图:一个动作/断言一张页面图(run_direct 设 FORGE_SHOT_DIR/FORGE_CASE 时启用)。
|
|
421
|
-
文件名 <case>_<序号>_<label>.jpeg,供 test-report.html 按步回放页面。
|
|
463
|
+
文件名 <case>_<序号>_<label>.jpeg,供 test-report.html 按步回放页面。
|
|
464
|
+
FORGE_SHOT_MODE=off 时跳过;fail 模式下 PASS 用例的过程图由 run_direct 统一清理。"""
|
|
422
465
|
import os as _os
|
|
466
|
+
if _os.environ.get("FORGE_SHOT_MODE") == "off":
|
|
467
|
+
return
|
|
423
468
|
base = _os.environ.get("FORGE_SHOT_DIR")
|
|
424
469
|
case = _os.environ.get("FORGE_CASE", "case")
|
|
425
470
|
if not base:
|
|
@@ -438,7 +483,7 @@ step_shot.n = {}
|
|
|
438
483
|
def wait_for(d, by, timeout=10, interval=0.5):
|
|
439
484
|
"""轮询等待控件出现(首屏锚点等待用);返回组件或 None,不抛异常"""
|
|
440
485
|
import time as _t
|
|
441
|
-
end = _t.time() + timeout
|
|
486
|
+
end = _t.time() + _w(timeout)
|
|
442
487
|
while _t.time() < end:
|
|
443
488
|
try:
|
|
444
489
|
c = d.find_component(by)
|
|
@@ -455,7 +500,7 @@ def text_exist(d, txt, wait=6):
|
|
|
455
500
|
契约:存在返回 True;不存在【抛 AssertionError】——永不返回 False。
|
|
456
501
|
run_direct/analyze_results 依赖异常文本做 FAIL 判定与失败聚类,勿改为静默返回。"""
|
|
457
502
|
import time as _t
|
|
458
|
-
deadline = _t.time() + wait
|
|
503
|
+
deadline = _t.time() + _w(wait)
|
|
459
504
|
while _t.time() < deadline:
|
|
460
505
|
try:
|
|
461
506
|
c = d.find_component(BY.text(txt, MatchPattern.CONTAINS))
|
|
@@ -463,14 +508,14 @@ def text_exist(d, txt, wait=6):
|
|
|
463
508
|
return True
|
|
464
509
|
except Exception:
|
|
465
510
|
pass
|
|
466
|
-
_t.sleep(0.
|
|
511
|
+
_t.sleep(0.25)
|
|
467
512
|
raise AssertionError("text not found: " + txt)
|
|
468
513
|
|
|
469
514
|
|
|
470
515
|
def text_value_equals(d, cid, expected, wait=6):
|
|
471
516
|
"""值断言(EXEC-040 实证+反证):getText() 精确等于期望——锚定 key 控件动态内容。"""
|
|
472
517
|
import time as _t
|
|
473
|
-
deadline = _t.time() + wait
|
|
518
|
+
deadline = _t.time() + _w(wait)
|
|
474
519
|
while _t.time() < deadline:
|
|
475
520
|
try:
|
|
476
521
|
c = d.find_component(BY.key(cid))
|
|
@@ -478,7 +523,7 @@ def text_value_equals(d, cid, expected, wait=6):
|
|
|
478
523
|
return True
|
|
479
524
|
except Exception:
|
|
480
525
|
pass
|
|
481
|
-
_t.sleep(0.
|
|
526
|
+
_t.sleep(0.25)
|
|
482
527
|
got = None
|
|
483
528
|
try:
|
|
484
529
|
c = d.find_component(BY.key(cid))
|
|
@@ -491,7 +536,7 @@ def text_value_equals(d, cid, expected, wait=6):
|
|
|
491
536
|
def text_value_contains(d, cid, part, wait=6):
|
|
492
537
|
"""值包含断言:getText() 包含子串(动态文案前缀不定时用)。"""
|
|
493
538
|
import time as _t
|
|
494
|
-
deadline = _t.time() + wait
|
|
539
|
+
deadline = _t.time() + _w(wait)
|
|
495
540
|
while _t.time() < deadline:
|
|
496
541
|
try:
|
|
497
542
|
c = d.find_component(BY.key(cid))
|
|
@@ -499,14 +544,14 @@ def text_value_contains(d, cid, part, wait=6):
|
|
|
499
544
|
return True
|
|
500
545
|
except Exception:
|
|
501
546
|
pass
|
|
502
|
-
_t.sleep(0.
|
|
547
|
+
_t.sleep(0.25)
|
|
503
548
|
raise AssertionError(f"text value not containing {part!r} (key={cid})")
|
|
504
549
|
|
|
505
550
|
|
|
506
551
|
def count_equals(d, target, expected, wait=6):
|
|
507
552
|
"""计数断言(EXEC-040 实证):key 或 CONTAINS 文本匹配的组件数==期望。"""
|
|
508
553
|
import time as _t, re as _re
|
|
509
|
-
deadline = _t.time() + wait
|
|
554
|
+
deadline = _t.time() + _w(wait)
|
|
510
555
|
last = -1
|
|
511
556
|
while _t.time() < deadline:
|
|
512
557
|
try:
|
|
@@ -519,14 +564,14 @@ def count_equals(d, target, expected, wait=6):
|
|
|
519
564
|
return True
|
|
520
565
|
except Exception:
|
|
521
566
|
pass
|
|
522
|
-
_t.sleep(0.
|
|
567
|
+
_t.sleep(0.25)
|
|
523
568
|
raise AssertionError(f"count mismatch: expect {expected} got {last} (target={target})")
|
|
524
569
|
|
|
525
570
|
|
|
526
571
|
def is_below(d, cid, ref_cid, wait=6):
|
|
527
572
|
"""几何断言(EXEC-040 实证):cid 的 top >= ref_cid 的 bottom(垂直次序)。"""
|
|
528
573
|
import time as _t
|
|
529
|
-
deadline = _t.time() + wait
|
|
574
|
+
deadline = _t.time() + _w(wait)
|
|
530
575
|
while _t.time() < deadline:
|
|
531
576
|
try:
|
|
532
577
|
a = d.find_component(BY.key(cid)); b = d.find_component(BY.key(ref_cid))
|
|
@@ -534,7 +579,7 @@ def is_below(d, cid, ref_cid, wait=6):
|
|
|
534
579
|
return True
|
|
535
580
|
except Exception:
|
|
536
581
|
pass
|
|
537
|
-
_t.sleep(0.
|
|
582
|
+
_t.sleep(0.25)
|
|
538
583
|
raise AssertionError(f"geometry: {cid} not below {ref_cid}")
|
|
539
584
|
|
|
540
585
|
|
|
@@ -542,7 +587,7 @@ def toast_exist(d, txt, wait=4, poll=0.5):
|
|
|
542
587
|
"""Toast 断言(v3.1):轮询 hilog 捕获 Toast 文本。需 FORGE_HDC/FORGE_DEVICE。"""
|
|
543
588
|
import os as _os, subprocess as _sp, time as _tt
|
|
544
589
|
hdc = _os.environ.get("FORGE_HDC", "hdc"); dev = _os.environ.get("FORGE_DEVICE", "127.0.0.1:5555")
|
|
545
|
-
deadline = _tt.time() + wait
|
|
590
|
+
deadline = _tt.time() + _w(wait)
|
|
546
591
|
while _tt.time() < deadline:
|
|
547
592
|
try:
|
|
548
593
|
r = _sp.run([hdc, "-t", dev, "shell", "hilog", "-x"],
|
|
@@ -599,7 +644,7 @@ def hilog_tail(tag=None, n=400):
|
|
|
599
644
|
def toast_text_seen(d, txt, timeout=6):
|
|
600
645
|
"""Toast 文本断言(dump 版)。无 ID 例外:Toast 为瞬时浮层无稳定 ID,且 hypium find_component
|
|
601
646
|
实测看不到 Toast 子窗口,故以 uitest dumpLayout 原文轮询(首轮实测教训 Round-1)。"""
|
|
602
|
-
deadline = time.time() + timeout
|
|
647
|
+
deadline = time.time() + _w(timeout)
|
|
603
648
|
while time.time() < deadline:
|
|
604
649
|
raw = _dump_layout()
|
|
605
650
|
if txt in raw:
|
|
@@ -127,8 +127,17 @@ def main():
|
|
|
127
127
|
'',
|
|
128
128
|
'- DEVICE:环境问题,重跑即可,不计用例失败',
|
|
129
129
|
'- DESIGN-ERROR:回 cases.json 修正 target(引用 runtime_pages 中真实存在的文本)',
|
|
130
|
-
'- NAV-CONTEXT-LOSS:先
|
|
131
|
-
'- ASSERT-FAIL
|
|
130
|
+
'- NAV-CONTEXT-LOSS:先 `probe.py hilog JSAPP 800` 查目标入口点击是否有路由事件——无事件即 EXEC-011 门禁,改判 BLOCKED-GATE;有事件则 `probe.py tree --grep <入口文案>` 查 Tab 切换/back 链(EXEC-012)',
|
|
131
|
+
'- ASSERT-FAIL:看截图核状态,属数据/时序断言失败;定案用 `probe.py db-pull <pkg>` + `probe.py db-sql <db> "<sql>"` 对照业务落库(Grit 实证有效)',
|
|
132
|
+
'',
|
|
133
|
+
'## 下一步命令(照抄执行,禁直接 --all)',
|
|
134
|
+
'',
|
|
135
|
+
'```bash',
|
|
136
|
+
'# DEVICE → 环境恢复后: run_direct.py --retry-failed',
|
|
137
|
+
'# DESIGN-ERROR → probe.py find <id> / ids 核对(ids.json vs 运行时)',
|
|
138
|
+
'# 修复验证 → run_direct.py --case <失败用例>(调试期可 FORGE_WAIT_SCALE=0.5 加速失败复现)',
|
|
139
|
+
'# 修产品码后 → run_direct.py --retry-failed(范围回归自动收窄)',
|
|
140
|
+
'```',
|
|
132
141
|
]
|
|
133
142
|
|
|
134
143
|
report = '\n'.join(lines)
|