// 行为判据的设备侧 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_.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'); }