@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.
@@ -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.6",
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'], `运行 npx --yes @harmonyos-arkts/d2h@${D2H_VERSION} install 安装兼容工具组合;本地 npm link 需由开发者显式管理。`);
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
- python3 $F/run_direct.py --all --dir testcases --device <sn> # 自动采集 end/fail 截图
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
- # 全类型探测(按钮常为 Button 非 Text——TD-008 实证:Text-only 扫描漏掉隐私页同意按钮)
65
- if d.find_component(BY.text(t)) is not None:
66
- d.touch(BY.text(t))
67
- dismissed.append(t)
68
- hit_any = True
69
- time.sleep(1.2)
70
- break # 点一个后重新扫(弹窗可能叠加/消失后露下一个)
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(2.5)
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.5)
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.5)
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.5)
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.5)
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.5)
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.5)
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.5)
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.5)
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.5)
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.5)
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.5)
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.5)
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:先 hdc hilog 查目标入口点击是否有路由事件——无事件即 EXEC-011 门禁,改判 BLOCKED-GATE;有事件则查 Tab 切换/back 链(EXEC-012)',
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)