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:
2026-09-18 04:42:03 +08:00
parent 590a72a715
commit b84880f14b
4 changed files with 386 additions and 10 deletions

View 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');
}