Files
MailUI4Agents/client/electron/test/lib/harmony-device.mjs
JianFeeeee 4f386199cf 修复: 集合指纹**不是集合的函数**(顺序敏感 + 只含 name:size);"读不到"曾静默报成"全 0 且指纹正常";"设备忙"的跳过**加界**;criteria-hygiene 两处按字面量裁射程
pi 2026-09-18 报的两个洞我**都当场复现了**(不是我信了,是跑出来了),修完都验了阳性对照。

## 1. 集合指纹必须是集合的函数(pi 洞 1,两个都复现)

原来 `'\n'.join(f'{f}:{getsize(f)}' for f in listed)`。实测:

· **顺序敏感**:只把 `jobs.manifest.json` 反序(集合/内容/计数全不变)
  ⇒ `sha=02502771` → `3439e049`。于是"集合变了 ⇒ 一眼看得出"失效,
  **每次清单整理都假变**。
· **只含 name:size**:字节级等长改写(`jobs-one.json` 的 `name: 'image'` → `'imoge'`,
  265 字节不变)⇒ **指纹仍是 02502771**。更尖锐的是 file 路径改成等长的
  `ApiClienX.ets`(指向不存在的文件)时 `ran/skipped/on_new_criteria` 全变而
  **指纹不变** —— **"集合没变而数变了"恰恰是它声称要抓的情况,它抓不到。**

⇒ `sorted()` + 内容哈希。实测:原序/反序/排序三种**同一哈希**(`33ff3bea`);
等长改内容 ⇒ 变(`89b2aa41`)。

## 2. "读不到" ≠ "不存在"(pi 洞 2,复现 + 比 pi 说的更糟)

隔离副本 + `runuser -u nobody` + `chmod 644 jobs/` 实测:
```
nobody: mutants=0 ran=0 skipped=0 … 原始条目 0   sha=e3b0c442   rc=0
```
`e3b0c442` = **空字符串的 sha256**;而且退出码 **0**。根因:`exists()` 对
**不可进入目录里的文件**返回 **False**(实测),12 个全被跳过;而 `glob` 那一半
照样列得出 12 个 ⇒ `unlisted`/`ghosts` 全空 ⇒ **"清单与磁盘不一致"那条警告一声不响**。
**同一份权限,两个半边给出互相矛盾的结论。**

⚠️ 比 pi 说的更糟的一点:那行**同时**印着 `清单 12 个 job 文件` 和 `sha=e3b0c442`
—— 一句话里说"12 个"却一个都没读到。

⇒ ① 判目录可进入性(`access(R_OK|X_OK)`),读不到就 `✗✗` 明说数字**全部无效**、
并点明"这不是空集合";② `open` 接住 `OSError`,把读不到的文件**攒起来一次报全**
(原来会死在第一个文件上抛 `PermissionError`,让人以为"就这一个");
③ 退出码 **2**(本仓约定:2=环境)—— 读不到就是没读数,而**没读数不是成功**。
④ `unreadable` 与 `ghosts` **分开报**:修法完全不同(修权限 vs 删条目)。

★ 这里我先写了个**不可达的分支**:`unreadable` 先探后读,而真读时 `PermissionError`
会提前抛出 ⇒ 那段报告永远走不到("判据在,但走不到",这次长在报告分支上)。
自己查出来并改成"读的时候接住",才让它成为可达的真分支。

## 3. "不抢前台"的跳过**加界**(pi 2026-09-18 §3,我接受)

只跳过不设界,"设备忙"会变成到期判据的**永久灰区**:不算红不算绿 ⇒ 永远不必被升级
—— 到期机制要防的正是这个,只是入口换成了"设备忙"。⇒ 连续 K(默认 3)轮没跑成,
**跳过自己变红**并给出接管路径(K 轮内是礼貌,K 轮外是闹钟)。

边界:**只对"设备在、前台不是我们的"计数**;**设备不在不计数也不变红** ——
那是 `PROBES.device` 的既有裁定(没装 SDK 的机器不该天天假红),超出本模块职责。

账本 `.tmp/harmony-busy-skips.json`(已 gitignore):判"**这台机器上**连续多少轮没验成",
换机器不继承。实测 1→2→3 轮 skip、第 4 轮起 FAIL;跑成一次即清零、再从 1 重新数。
自检(不连设备)把 ①K 轮内礼貌 ②超了必红 ③跑成清零 ④上限可覆盖 都钉住,
变异验证 M7(over 恒 false)⇒自检②红、M8(noteRan 不清零)⇒自检③红。

## 4. 顺带修掉 criteria-hygiene 自己两处"按字面量裁射程"

我按纪律把账本读取从裸 `readFileSync` 改成 `prose()`(`criteria-hygiene` 立刻红,
**那条判据是对的、我错了**),接着暴露出该判据自身两个洞:

· **import 按写法硬匹配**:只认 `'./lib/read.mjs'`/`'../lib/read.mjs'`,
  而 `harmony-device.mjs` **就在 `lib/` 里**、按惯例写 `'./read.mjs'` ⇒
  假红"根本没 import"(其实 import 了、运行时完全正常)。改成**解析说明符后与
  `SELF` 比**(本仓已用"解析后比较"解决过同一族:`abspath`、`join(HERE,…)`)。
· **用 `prose()`(原文)判"用没用"** ⇒ **注释里**写 `prose(…)` 就算用了
  (实测该文件命中 3 处、只有 1 处是真调用)。改成 `code()`。这与它上面那条
  "不许裸用 readFileSync"踩过的是同一个坑,我在那条上写了理由、**这条漏了**。

两处修法都做了承重验证:删真 import ⇒ 红;import 换成 `code as prose2`(别名)⇒ 红;
只在注释里写 `prose(` ⇒ **绿**(对照旧实现:同一份样本 ⇒ **红**)。

## 验证

· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9` —— 红的仍是同样 9 条(都是并发
  会话的"自报 > 清单"与 4 条 exit 1),我中途引入的两条(裸 readFileSync、import 假红)
  已消。`mutants=48` 不变。
· 所有 chmod/清单反序/等长改写**都已还原**,`git status` 只剩本次 5 个文件。
2026-09-18 04:54:59 +08:00

243 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 行为判据的设备侧 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';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
// 读盘走 test/lib 的具名入口(本仓纪律:判据目录里不许裸 readFileSync)。
// 这里要的是**原文**(账本是个 JSON 文本,读出来自己解析)⇒ `prose()` 正是那个入口。
// ★ 实测教训:我第一版在这儿裸用了 readFileSync,`criteria-hygiene` 立刻红了
// (报 `test/lib/harmony-device.mjs:57`)—— 那条判据是对的,错的是我这段代码。
// 顺带说明那条判据的形状为什么对:它扫的是"**能不能换用更准的入口**",
// 而我当时的理由是"这是运行时状态、不是仓库源码"——那理由不成立,
// `prose()` 读的就是任意文本,跟文件性质无关。
import { prose } from './read.mjs';
const TOOLCHAIN_HDC = '/opt/huawei/command-line-tools/sdk/default/openharmony/toolchains/hdc';
/*
* ─── 有界的"不抢前台"(pi 2026-09-18 §3)───
*
* 问题:本模块按"应用不在前台就跳过"设计(别人的会话在用这台模拟器)。
* 但**跳过必须有界** —— 如果设备一直被占着,那 7 条到期判据会永远停在
* "既不绿也不红",这恰恰是到期机制要防的东西("不等谁想起来")。
* "不抢前台"一旦变成永久状态,就等于给到期判据开了一个**永久灰区**:
* 它们不算红、不算绿,也就永远不需要被升级 —— "判据存在但永远不会响"的又一个变体,
* 只是这次入口是"设备忙"。
*
* ⇒ 连续跳过 K 轮之后,**跳过自己变红**(报"设备连续被占,本次仍未验证")。
* K 轮之内是礼貌(不抢别人),K 轮之外是闹钟(到期机制不许静默)。
*
* 账本落在 `.tmp/`(已在 .gitignore 里):它是**本机状态**,不是仓库内容 ——
* 换一台机器/清掉 .tmp 就等于"重新开始数",这是对的:判的是"**这台机器上**
* 连续多少轮没验成",换机器不继承。
*
* ★ 只对"设备在、但前台不是我们的"计数(busy)。**设备不在**不计数、也不变红 ——
* 那是 run-all.mjs 里 PROBES.device 的既有裁定("没装 SDK 的机器不该天天假红"),
* 超出本模块的职责:探针已经决定"不到期",行为部分自然不该自己变红。
*
* ⚠️ 账本路径与上限都**惰性读 env**(不是 import 时定死):否则判据自检没法验
* "超限会不会红" —— 测不了的边界等于没写(本仓纪律)。与 run-all.mjs 里
* `AGENTMAIL_PROBE_DEVICE` 覆盖同一个理由。
*/
const ledgerPath = () => process.env.AGENTMAIL_BUSY_LEDGER
|| join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '.tmp', 'harmony-busy-skips.json');
const busyLimitOf = () => Number(process.env.AGENTMAIL_BUSY_SKIP_LIMIT || 3);
function readLedger() {
try {
const d = JSON.parse(prose(ledgerPath()));
return (d && typeof d === 'object') ? d : {};
} catch {
return {}; // 没有/坏了都当"从 0 开始数",不是错误
}
}
function writeLedger(d) {
try {
mkdirSync(dirname(ledgerPath()), { recursive: true });
writeFileSync(ledgerPath(), JSON.stringify(d, null, 2));
} catch { /* 写不了账本不该让判据崩:退化成"永远不超限",由下面的返回值明说 */ }
}
/**
* 记一次"设备忙,跳过",返回 `{ streak, over }`。
*
* `over === true` ⇒ 连续跳过已超 K 轮,调用方**必须变红**(不再是礼貌跳过)。
* 调 `noteRan()` 清零(真跑成了就不该再记前账)。
*/
export function noteBusySkip(criterion) {
const d = readLedger();
const n = (d[criterion] || 0) + 1;
d[criterion] = n;
writeLedger(d);
return { streak: n, over: n > busyLimitOf() };
}
/** 该判据真的跑成了 ⇒ 连续计数清零。 */
export function noteRan(criterion) {
const d = readLedger();
if (d[criterion]) { delete d[criterion]; writeLedger(d); }
}
/** 只读查询当前连续跳过轮数(判据自检用)。 */
export function busyStreak(criterion) {
return readLedger()[criterion] || 0;
}
/** 供报文用:本机繁忙上限 K。 */
export const busyLimit = () => busyLimitOf();
/**
* 找到能跑的 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');
}