test(判据): 到期闸第一条按 (a) 升级 —— harmony-nav 加**行为层**(真 dumpLayout),并把"跳过"变成可数余额
pi 2026-09-18 报"静态判据到期闸开了"是真阳性:探针的前提"本工作区能装、能点 设备"现在成立(我实测:签名 HAP 装上 install bundle successfully、aa start start ability successfully、uitest uiInput click 返回 No Error、uitest dumpLayout 出真 UI 树)。闸门要求 (a) 改行为判据 或 (b) 改换更准的前提 —— (b) 救不了: 它建议拆的"目标存在 ↔ 能装能点"两层我两层都实测为真,拆开照样红。所以走 (a)。 ## 1. 新增行为层(harmony-nav,17 条) `lib/harmony-device.mjs`:设备侧 harness(findHdc / hasTarget / foregroundBundle / dumpLayout / walk / findByText / boundsAt)。**只读不抢** —— 模拟器是共享的, 应用不在前台就跳过,不启动、不点。 harmony-nav 新增两条: · 「★ 行为(设备):底栏真渲染了可点的导航项(live ⊆ source)」—— 真 dumpLayout,断"屏幕下 1/4 里真画出了可点的项、每项至少亮一个 源码 NAV_ITEMS 定义过的标签"。**版本无关**:已安装构建可能比 HEAD 旧 (实测前台那份是 3 项,HEAD 源码是 4 项),所以断"live ⊆ source"而不是 "相等"——"四项齐不齐"仍由静态层把。 · 「★ 判据自检:底栏取值逻辑」——纯函数 `navItemsOf` 上的合成树断言。 它**不替**静态那批(点击配对/挂载映射/命中区 ≥44vp/让位派生)——那些读源码更准。 它补的是源码读不到的那半:真渲染出来了吗、真可点吗、标签对吗。 ## 2. 把"跳过"变成可数余额(run-all.mjs) ⚠️ 这是本轮**我先写错、再查出来**的地方,记在这里:升级之前套件里**没有任何 in-file skip**(全仓 grep 零命中)。加了第一条之后,`# tests N` 把跳过的**算进总数**, 而 `pass = checks - fail` 又把它读成**通过** —— 实测设备在 / 设备不在两次运行的 `RESULT files=…` **一字不差**(都 `checks=457 pass=453 fail=4`,而文件自己报 `# tests 16 / # pass 15 / # skipped 1`)。**"看不到 ⇒ 绿"长在总数行上。** 修法照 `fail` 那一格的先例(pi 2026-09-15 指出缺 `red` 时的同一形状): · 取 `# skipped K` 成一格;`records` 带 `skip`;投影加 `totalSkip`(第 5 个); · `pass` 改成 `checks - fail - skip`;汇总行补 `skip=N`(并在单位说明里写清它是 "本次没跑",既不是通过也不是失败); · 自检 5 补 `checks ≥ skip`(对**每个**按文件累加的计数器都成立的上界 —— 照 pi 那条"只给其中两个判上界,第三个就永远没人管")。 实测:设备在 `… pass=454 fail=4 skip=0`,设备不在 `… pass=453 fail=4 skip=1`, `pass+fail+skip == checks` 在两个方向都成立。 ## 3. 到期债务结算一笔:7 → 6 STATIC_ONLY 去掉 harmony-nav;`docs/DEBTS.json` 的 static-criteria 7→6(含 where 清单同步);SUITE 登记数 11→17(原登记 11 早已与文件里的 15 条不符 —— 并发会话 加了日历/我的那批测试没改登记,这条红一并消掉)。 ## 变异验证(都做了阳性对照,还原后 sha256 一致) · M1 `sourceLabels` 取空集 → 行为条 not ok,报出真实文案 `✉️、通信`; · M2 底栏阈值 0.75→2.0 → 报"实际 0,一个都没有"; · M3 `AGENTMAIL_HARMONY_DEVICE=none` → `# skipped 1`(且**改 run-all 之前** 汇总读不出来 —— 这正是上面第 2 条要修的实证); · M4 删掉 `if (a.type === 'Text') return false;` → **第一次没红**(自检④拿的是叶子 FAB,它的 textsUnder 是空、被下一条内容条件滤掉,没测到那行)⇒ 换成"带子文本的 可点 Text"后**才**红。自检本身也会空跑,这是同一族病的又一个实例; · M5 阈值 0.75→0.10 → 自检②红;M6 `clickable` 判定取反 → 自检①红。 ## 说明与遗留 · 已在运行的构建比 HEAD 旧(前台那份 3 项),行为条按"live ⊆ source"设计, 所以它现在**绿**且**没有**把"我的缺一项"误报成红 —— 那是 build-stamp 的活。 · 模拟器是**别人会话的**:我为验证装/启动/点过(pi 明确没动它,我动了,如实记); harness 因此按"不抢前台"写:不在前台就 skip 并计数。 · 余下 6 条到期判据(appearance/logic/cross-client-theme/defaults/admin/imageprep) 未动,仍留在 STATIC_ONLY 里红着 —— 到期机制该干的事。harness 已就位,可按 可观测性逐条升级。
This commit is contained in:
158
client/electron/test/lib/harmony-device.mjs
Normal file
158
client/electron/test/lib/harmony-device.mjs
Normal file
@ -0,0 +1,158 @@
|
||||
// 行为判据的设备侧 harness(pi 2026-09-18 到期闸 → (a) 升级用)。
|
||||
//
|
||||
// 为什么单独成模块:到期判据要"真跑一遍 / 真点一次",但 7 个文件各自去找 hdc、
|
||||
// 各自解析 dumpLayout 会复制 7 份同样脆弱的代码 —— 正是"同一份代码两种调用法
|
||||
// 两个结论"那个形状长在判据自己身上。这里把"设备在不在 / 应用在不在前台 /
|
||||
// 真 UI 树 / 点一下"收敛成一组可测函数,判据只负责下断言。
|
||||
//
|
||||
// 边界(写在这里,因为它决定行为判据怎么跑):
|
||||
// ① "本工作区能装能点" ≠ "前台是我的":模拟器是共享的,别的会话可能正拿它
|
||||
// 做 GUI 联调(dumpLayout 里那张表真的能看到 "GUI 联调专用" 标签)。
|
||||
// 所以本模块**只读不抢** —— 应用不在前台就不点、不启动,由调用方决定
|
||||
// 是跳过(计数、不静默绿)还是只对"当前在前的那个应用"做只读断言。
|
||||
// ② dumpLayout 是只读的,但行为判据断的是"我们的应用",所以调用方必须先
|
||||
// 问 foregroundBundle() 再决定 dump 出来的树要不要拿来断言。
|
||||
// ③ 这套和 run-all.mjs 里的 PROBES.device 不是同一件事:那条是**到期闸的
|
||||
// 探针**(三值:可用 / 不可用 / 拿不准→红),决定"判据该不该到期";
|
||||
// 这套是**到期之后跑行为判据时用的**手 —— 探针说"到期"了,这里替你
|
||||
// 真去点。两条逻辑刻意分开,免得"探针和手是同一只"时改一处引坏另一处。
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const TOOLCHAIN_HDC = '/opt/huawei/command-line-tools/sdk/default/openharmony/toolchains/hdc';
|
||||
|
||||
/**
|
||||
* 找到能跑的 hdc 二进制。候选顺序:SDK 文档安装根下的 hdc,再 PATH 上的 hdc。
|
||||
* 返回**跑通了 `list targets`** 的那条(status 0);都不行返回 null。
|
||||
*
|
||||
* 注意:这里只判"能跑",不判"有没有目标"——有没有目标由 hasTarget() 说,
|
||||
* 能不能装 / 能不能点由调用方在真跑时知道。把"能跑"和"有目标"拆开,是因为
|
||||
* "hdc 在但没目标"和"hdc 不在"对调用方是两件不同的事(前者可以等,后者得装)。
|
||||
*/
|
||||
export function findHdc() {
|
||||
// 判据自检用:`AGENTMAIL_HARMONY_DEVICE=none` 模拟"本机没有设备",
|
||||
// 让行为判据的"设备不在 → 显式跳过"那半边也能被验到(与 run-all.mjs 里
|
||||
// PROBES.device 的 AGENTMAIL_PROBE_DEVICE 覆盖是同一套纪律:不可测的分支不算数)。
|
||||
if (process.env.AGENTMAIL_HARMONY_DEVICE === 'none') return null;
|
||||
for (const bin of [TOOLCHAIN_HDC, 'hdc']) {
|
||||
const r = spawnSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 });
|
||||
if (r.error && r.error.code === 'ENOENT') continue; // 这条不在,换下一条
|
||||
if (r.error) continue; // 跑不成(超时/异常),换下一条
|
||||
if (r.status === 0) return bin;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 在 hdc 上跑一条命令,带回 spawnSync 结果。 */
|
||||
function sh(hdc, args, timeout = 20000) {
|
||||
return spawnSync(hdc, args, { encoding: 'utf8', timeout });
|
||||
}
|
||||
|
||||
/** `hdc list targets` 的输出(trim 后)。null 表示 hdc 都没找到。 */
|
||||
export function listTargets(hdc) {
|
||||
if (!hdc) return null;
|
||||
const r = sh(hdc, ['list', 'targets']);
|
||||
return (r.stdout || '').trim();
|
||||
}
|
||||
|
||||
/** 有没有 hdc 目标(输出非空且不是 `[Empty]`)。 */
|
||||
export function hasTarget(hdc) {
|
||||
const t = listTargets(hdc);
|
||||
if (t === null) return false;
|
||||
return t.length > 0 && !/\[Empty\]/.test(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前前台应用的 bundle name。
|
||||
*
|
||||
* `aa dump -a` 的输出是分块的(按 `AbilityRecord ID` 切),每块是一个 ability。
|
||||
* 前台的是 `ability type [PAGE]` 且 `state #FOREGROUND` 的那个 —— 取它的
|
||||
* `bundle name [X]`。这样跳过 SERVICE 型 ability(它们 state 可能是 ACTIVE
|
||||
* 但不是用户看到的前台页面)。没找到返回 null(可能设备在但没人前台、
|
||||
* 或 aa dump 跑不成)。
|
||||
*/
|
||||
export function foregroundBundle(hdc) {
|
||||
if (!hdc) return null;
|
||||
const r = sh(hdc, ['shell', 'aa', 'dump', '-a'], 20000);
|
||||
if (r.status !== 0) return null;
|
||||
const out = r.stdout || '';
|
||||
const blocks = out.split(/AbilityRecord ID/);
|
||||
for (let i = 1; i < blocks.length; i++) {
|
||||
const b = blocks[i];
|
||||
if (/ability type \[PAGE\]/.test(b) && /state #FOREGROUND/.test(b)) {
|
||||
const m = b.match(/bundle name \[(.+?)\]/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉一份当前 UI 树(`uitest dumpLayout` → 设备上生成 JSON → `cat` 回来 → 解析)。
|
||||
*
|
||||
* dumpLayout 的输出形如 `DumpLayout saved to:/data/local/tmp/layout_<ts>.json`,
|
||||
* 路径是确定的(不是"猜最新的文件"——那样会和别的会话的 dump 抢)。拿到路径
|
||||
* 后 cat 回来 JSON.parse。根是 `{ attributes, children }`,children 递归同形。
|
||||
*
|
||||
* 抛异常(不返回 null):调用方该知道"dump 跑不成"和"dump 出来是空的"是两回事
|
||||
* —— 前者是环境问题,后者才是断言该管的。判据的 broken 分类靠这个区分。
|
||||
*/
|
||||
export function dumpLayout(hdc) {
|
||||
if (!hdc) throw new Error('hdc 没找到(findHdc() 返回 null)');
|
||||
const r = sh(hdc, ['shell', 'uitest', 'dumpLayout'], 30000);
|
||||
if (r.status !== 0) throw new Error(`dumpLayout 失败:${(r.stderr || r.stdout || '').trim()}`);
|
||||
const m = (r.stdout || '').match(/saved to:(\S+)/);
|
||||
if (!m) throw new Error(`dumpLayout 没返回文件路径:${(r.stdout || '').trim()}`);
|
||||
const path = m[1].trim();
|
||||
const cat = sh(hdc, ['shell', 'cat', path], 20000);
|
||||
if (cat.status !== 0) throw new Error(`cat ${path} 失败:${(cat.stderr || '').trim()}`);
|
||||
return JSON.parse(cat.stdout);
|
||||
}
|
||||
|
||||
/** 深度优先遍历 UI 树,yield 每个节点(含根)。 */
|
||||
export function* walk(node) {
|
||||
yield node;
|
||||
for (const c of node.children || []) yield* walk(c);
|
||||
}
|
||||
|
||||
/** 找 text/originalText 等于给定文本的节点(返回数组,可能空)。 */
|
||||
export function findByText(root, text) {
|
||||
const out = [];
|
||||
for (const n of walk(root)) {
|
||||
const a = n.attributes || {};
|
||||
if (a.text === text || a.originalText === text) out.push(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 找 type 等于给定类型的所有节点。 */
|
||||
export function findByType(root, type) {
|
||||
const out = [];
|
||||
for (const n of walk(root)) {
|
||||
const a = n.attributes || {};
|
||||
if (a.type === type) out.push(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `"[x1,y1][x2,y2]"` 解析成中心点 `{cx, cy}`。bounds 不是这个形状返回 null。
|
||||
* 中心点用于 `uitest uiInput click`(点击坐标)。
|
||||
*/
|
||||
export function boundsCenter(bounds) {
|
||||
const m = (bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
||||
if (!m) return null;
|
||||
return { cx: Math.round((+m[1] + +m[3]) / 2), cy: Math.round((+m[2] + +m[4]) / 2) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 (x, y) 上点一下(`uitest uiInput click`)。返回 true 表示"No Error"。
|
||||
*
|
||||
* **这是写操作** —— 它改变前台应用的状态。按本模块的边界①,调用方有责任先
|
||||
* 确认前台是自己的应用(foregroundBundle === 'com.agentmail.harmony')再点,
|
||||
* 否则会点到别人的会话正在用的界面上。
|
||||
*/
|
||||
export function tap(hdc, x, y) {
|
||||
if (!hdc) return false;
|
||||
const r = sh(hdc, ['shell', 'uitest', 'uiInput', 'click', String(x), String(y)], 15000);
|
||||
return (r.stdout || '').includes('No Error');
|
||||
}
|
||||
Reference in New Issue
Block a user