Files
nodeagent/tests/unit/ps-helper.test.mjs
T
jkooe 0dcf01ad4e perf(v14): PowerShell 常驻助手 —— GUI 能力提速 4~25 倍(审查报告 §5.5 P1)
## 问题(审查报告 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 噪声必须忽略)/ 中文多行往返无损)
2026-09-30 00:51:56 +08:00

49 lines
2.4 KiB
JavaScript
Raw Permalink 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.
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);
});