## 问题(审查报告 5.5 指出,真机实测确认) window.list / window.focus / screen.find 每次调用都起一个 powershell.exe 现编译 C# (Add-Type @"..."@)+ 重新加载 UIA 程序集。实测 agent 侧中位: window.list 1267ms · window.focus 1631ms · screen.find 3140~3736ms 固定成本(起进程 + 编译类型)与「本次要做的活」无关,纯浪费。 ## 方案(apps/agent/src/util/ps-helper.ts) 常驻 PowerShell 进程:启动时把 prelude(Win32 C# 类型 + UIA 程序集 + WinRT/OCR 段) **只加载一次**,之后每请求只发「本次逻辑」(base64 一行),进程内直接执行回传。 - 协议:stdin 收 base64 脚本行;stdout 回 `@@R <b64>` / `@@E <b64>`;stderr 只走就绪信号 - 请求 FIFO 串行(单进程单 stdin),逐请求超时 - **可用性不倒退**:助手不可用/超时/进程退出 → 自动回退到原一次性脚本路径; 连续失败 3 次熔断,本进程不再重试(避免每次白等一次超时) - 自愈:进程退出后在下次请求重建;空闲 10 分钟自动回收;agent 退出统一回收 - 预加载段带 sha256 指纹:**prelude 变化自动换新助手**(否则会用到旧进程里已加载的定义) - 逃生开关:环境变量 NODEAGENT_NO_PS_HELPER=1 可强制走一次性路径 ## 关键工程细节:exit 脚本体会被送进常驻进程执行,**其中的 exit 会杀掉助手进程**。 已将 focusOnce 的 `exit 0`、UIA 的 `exit 0`、OCR 的 2 处 `exit 0` 全部改为 return。 ## 顺带优化 UIA 扫描策略(本轮第二个瓶颈) UIA 只能「FindAll(Descendants) + 子串过滤」(PropertyCondition 是精确匹配)。 原来**每次都遍历整棵桌面 UI 树**。改为: 指定 window → 该窗口子树;否则**先在前台窗口子树内找**,未命中再退回全桌面兜底。 遍历逻辑收敛为预加载函数 Find-UiaByText,两处复用不再重复。 ## 真机实测(agent 侧耗时,同一台机器同一会话) | 能力 | 改造前 | 改造后 | |---|---|---| | window.list | 1267 / 1216 / 1260 / 980 ms | **52 / 52 / 52 / 73 / 42 ms**(约 25x) | | window.focus | 1576 / 1568 / 1600 ms | **411~443 ms**(约 4x;余量为刻意的等待与激活校验) | | screen.find | 3140 / 3736 / 1302 / 1393 ms | **19 / 26 / 72 / 103 / 176 ms**(约 20x,前台命中路径) | 端到端(CLI 视角)window.list 由 1.34s → **0.16s**。 另注:全桌面无命中路径的耗时随 UI 树规模变化,非固定值。 ## 单测 101 -> 105(新增助手协议 4 例:UTF-16LE 编码可逆 / bootstrap 含 prelude+READY+循环+exit 协议 且必须 try-catch 兜异常 / 协议行解析(含 Add-Type 噪声必须忽略)/ 中文多行往返无损)
49 lines
2.4 KiB
JavaScript
49 lines
2.4 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
buildBootstrap,
|
||
parseHelperLine,
|
||
toEncodedCommand,
|
||
} from '../../apps/agent/dist/util/ps-helper.js';
|
||
|
||
test('编码:UTF-16LE base64 可逆(规避引号/换行/中文转义)', () => {
|
||
const script = '$x = "中文 \'引号\'"\nWrite-Output $x';
|
||
const b64 = toEncodedCommand(script);
|
||
assert.equal(b64, Buffer.from(script, 'utf16le').toString('base64'));
|
||
assert.equal(Buffer.from(b64, 'base64').toString('utf16le'), script);
|
||
});
|
||
|
||
test('bootstrap:包含 prelude、READY 标记、请求循环与退出信号', () => {
|
||
const prelude = 'Add-Type -AssemblyName UIAutomationClient\nfunction Foo { 1 }';
|
||
const boot = buildBootstrap(prelude);
|
||
assert.ok(boot.includes(prelude), '必须内联 prelude(类型只加载一次)');
|
||
assert.ok(boot.includes("'@@READY'"), '必须发出就绪信号(通过 stderr,不污染协议 stdout)');
|
||
assert.ok(boot.includes('@@EXIT'), '必须支持退出信号');
|
||
assert.ok(boot.includes('[Console]::In.ReadLine()'), '必须逐行读取请求');
|
||
assert.ok(boot.includes('FromBase64String'), '请求应为 base64 解码');
|
||
assert.ok(boot.includes("'@@R '"), '成功响应前缀');
|
||
assert.ok(boot.includes("'@@E '"), '异常响应前缀');
|
||
// 关键:异常必须被 catch 住,否则一次脚本报错就会带走整个常驻进程
|
||
assert.ok(/catch\s*\{/.test(boot), '必须有 try/catch 兜住单次脚本异常');
|
||
});
|
||
|
||
test('协议行:正确解析成功/异常,噪声行为 null', () => {
|
||
const ok = parseHelperLine(`@@R ${Buffer.from('{"a":1}', 'utf8').toString('base64')}`);
|
||
assert.deepEqual(ok, { isErr: false, payload: '{"a":1}' });
|
||
|
||
const bad = parseHelperLine(`@@E ${Buffer.from('未找到窗口', 'utf8').toString('base64')}`);
|
||
assert.deepEqual(bad, { isErr: true, payload: '未找到窗口' });
|
||
|
||
// Add-Type / 程序集加载会往 stdout 夹杂质,必须被忽略而不是误当响应
|
||
assert.equal(parseHelperLine('Add-Type: 已加载程序集'), null);
|
||
assert.equal(parseHelperLine(''), null);
|
||
assert.equal(parseHelperLine('@@X abc'), null);
|
||
});
|
||
|
||
test('协议行:中文与多行输出往返无损', () => {
|
||
const text = '窗口「记事本」\n第二行\t制表';
|
||
const b64 = Buffer.from(text, 'utf8').toString('base64');
|
||
const parsed = parseHelperLine(`@@R ${b64}`);
|
||
assert.equal(parsed.payload, text);
|
||
});
|