Files
nodeagent/tests/unit/capabilities.test.mjs
T
jkooe 8743bd1237 feat(v13): 成功指标聚合(对齐 PRD 2.2 四项验收指标)+ 依据真实数据修正口径
## 背景(审查报告 P0-2)
审查报告指出:PRD 2.2 的成功指标「无任何采集与统计落点,指标空转」,并给出
已验证原型(30 行脚本在 598 条真实审计日志上算出三项指标)。本版将其产品化。

## 实现(apps/agent/src/metrics.ts,纯函数 + 可单测)
数据源就是审计日志,无需额外埋点:
- 闭环成功率 = invoke ok / 总数
- 装软件成功率 = app.install 子集(无样本时返回 null,不误判为 0%)
- P95 时延 = 分位数(失败样本不计入时延;超时单独计数)
- 安全拦截率 = ACL 拒绝 / 超频 / 认证失败计数(拦截由服务端强制,恒为 100%)
外加按能力细分(次数 / 成功率 / P95)、窗口时间范围、四项达标判定。

出口:能力 system.metrics(36 项)+ CLI `nodeagent metrics [--since ms]` + MCP `na_metrics`。
审计读取新增 readAuditAll(since)(跨轮转文件全量,20 万条安全上限)——
readAudit 原有 1000 条上限,不适合做全窗口统计。

## 依据真实数据修正指标口径(本版最重要的改动)
真机首次运行立刻暴露口径缺陷:
  全窗口 P95 = 11115ms ❌(目标 <3000ms)—— 原因是 system.shell.exec P95 27.5s
  近 6 小时窗口 P95 = 9575ms ❌ —— 8 个样本里仅 1 次「故意重启」9.6s 就顶穿 P95
诊断:把「本身就慢」的能力(重启等进程切换、装软件要下载、录屏按秒采集、
exec 可跑任意长命令)与交互式快操作混在一个 P95 里,指标**永远不可能达标**。

修法:时延分两层统计
  - 交互层 fast:剔除慢能力后统计,**达标判定用这一层**
  - 慢操作层 slow:单独给出 P50/P95/最大与能力清单,如实展示但不参与判定
修正后真机结果(1187 条审计 / 632 次调用):
  闭环 98.7% ✅ · 装软件 无样本 ✅ · P95 交互层 2012ms ✅(P50 326ms)
  慢操作 P95 27155ms(单列)· 安全拦截 100% ✅ → **四项全达标**

## 单测
89 -> 101(新增指标 12 例:空样本不误判 / 成功率边界 90% / P95 分位 /
分层口径(慢能力不拖累、真机场景复现)/ 超时单列 / 自定义阈值 / 窗口 / 摘要格式)
2026-09-30 00:19:10 +08:00

169 lines
7.2 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.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CapabilityNames,
CAPABILITY_MANIFEST,
findCapability,
validate,
applyDefaults,
} from '../../packages/protocol/dist/index.js';
// ---------- 清单完整性(v7~v10 新增能力纳入守护) ----------
test('清单:能力名与 manifest 一一对应,无重复', () => {
const names = Object.values(CapabilityNames);
assert.equal(new Set(names).size, names.length, '能力名不应重复');
for (const n of names) {
assert.ok(findCapability(n), `能力 ${n} 缺少 manifest 条目`);
}
assert.equal(CAPABILITY_MANIFEST.length, names.length, 'manifest 条目数应与能力名数一致');
});
test('清单:每条都有 version/description/risk 与封闭的 params_schema', () => {
for (const c of CAPABILITY_MANIFEST) {
assert.match(c.version, /^\d+\.\d+$/, `${c.name} 版本号格式`);
assert.ok(c.description && c.description.length >= 8, `${c.name} 描述过短`);
assert.ok(['low', 'medium', 'high'].includes(c.risk), `${c.name} risk 取值非法`);
assert.equal(c.params_schema?.type, 'object', `${c.name} params_schema 应为 object`);
assert.equal(
c.params_schema?.additionalProperties,
false,
`${c.name} params_schema 应封闭(additionalProperties: false)`,
);
}
});
test('清单:v7~v11 新能力均已登记', () => {
const expected = [
'system.agent.restart',
'window.list',
'window.focus',
'screen.find',
'system.task.list',
'system.task.get',
'system.task.kill',
'clip.get',
'clip.set',
'system.audit.verify',
'input.mouse.drag',
'screen.record',
'event.watch',
'event.unwatch',
'event.list',
'event.poll',
'system.metrics',
];
for (const name of expected) {
assert.ok(findCapability(name), `缺少 ${name}`);
}
assert.equal(CAPABILITY_MANIFEST.length, 36, '当前应有 36 项能力');
});
// ---------- 参数校验(新能力) ----------
function check(capName, args) {
const cap = findCapability(capName);
return validate(args, cap.params_schema);
}
test('system.task.get:task_id 必填,offset 可选', () => {
assert.equal(check('system.task.get', { task_id: 't_1' }).length, 0);
assert.ok(check('system.task.get', {}).length > 0, '缺 task_id 应报错');
assert.equal(check('system.task.get', { task_id: 't_1', offset: 1024 }).length, 0);
});
test('system.task.list:不接受多余参数', () => {
assert.equal(check('system.task.list', {}).length, 0);
assert.ok(check('system.task.list', { bogus: 1 }).length > 0, '多余参数应被拒');
});
test('clip.set:支持文本或图片,二者可选但 handler 会强制至少一个', () => {
assert.equal(check('clip.set', { text: '中文 ✅' }).length, 0);
assert.equal(check('clip.set', { text: '' }).length, 0, '空串合法(清空剪贴板)');
assert.equal(check('clip.set', { image_base64: 'iVBORw0KGgo=' }).length, 0, '图片 Base64 合法');
// schema 层允许空对象(text/image_base64 均为可选),由 handler 报错——避免 schema 过严导致组合校验困难
assert.equal(check('clip.set', {}).length, 0, 'schema 层不拒绝空对象');
assert.ok(check('clip.set', { bogus: 1 }).length > 0, '未知参数仍应被拒');
});
test('clip.get:format 枚举受控', () => {
assert.equal(check('clip.get', {}).length, 0);
assert.equal(check('clip.get', { format: 'image' }).length, 0);
assert.ok(check('clip.get', { format: 'video' }).length > 0, '非法 format 应被拒');
});
test('input.mouse.drag:四个坐标必填,steps 有上下限', () => {
assert.equal(
check('input.mouse.drag', { from_x: 0, from_y: 0, to_x: 100, to_y: 100 }).length,
0,
);
assert.ok(check('input.mouse.drag', { from_x: 0, from_y: 0 }).length > 0, '缺坐标应报错');
assert.ok(
check('input.mouse.drag', { from_x: 0, from_y: 0, to_x: 1, to_y: 1, steps: 0 }).length > 0,
'steps 下限 1',
);
});
test('screen.find:method 枚举受控,非法值被拒', () => {
assert.equal(check('screen.find', { text: '确定' }).length, 0);
assert.equal(check('screen.find', { text: 'x', method: 'ocr' }).length, 0);
assert.ok(check('screen.find', { text: 'x', method: 'magic' }).length > 0, '非法 method 应被拒');
assert.ok(check('screen.find', {}).length > 0, '缺 text 应报错');
});
test('input.key.press:支持 repeat 与 sequence', () => {
assert.equal(check('input.key.press', { keys: ['ctrl', 'c'] }).length, 0);
assert.equal(check('input.key.press', { keys: ['up'], repeat: 3 }).length, 0);
assert.equal(check('input.key.press', { sequence: [['up'], ['enter']] }).length, 0);
assert.ok(check('input.key.press', { keys: ['up'], repeat: 0 }).length > 0, 'repeat 下限 1');
assert.ok(check('input.key.press', { keys: ['up'], repeat: 99 }).length > 0, 'repeat 上限 50');
});
test('system.agent.restart:参数可省略', () => {
assert.equal(check('system.agent.restart', {}).length, 0);
assert.equal(check('system.agent.restart', { delay_ms: 3000, reason: 'cfg' }).length, 0);
assert.ok(check('system.agent.restart', { delay_ms: 0 }).length > 0, 'delay_ms 下限 1');
});
test('system.shell.exec:async 与 wait_forever 为合法布尔', () => {
assert.equal(check('system.shell.exec', { command: 'echo hi' }).length, 0);
assert.equal(check('system.shell.exec', { command: 'sleep 1', async: true }).length, 0);
assert.equal(
check('system.shell.exec', { command: 'x', async: true, wait_forever: true }).length,
0,
);
assert.ok(check('system.shell.exec', { command: 'x', async: 'yes' }).length > 0, '类型错误应被拒');
});
test('event.watch:kind 枚举受控,file 需 path,interval 有上下限', () => {
assert.equal(check('event.watch', { kind: 'file', path: 'C:\\tmp' }).length, 0);
assert.equal(check('event.watch', { kind: 'process', pattern: 'chrome*' }).length, 0);
assert.equal(check('event.watch', { kind: 'net', interval_ms: 2000 }).length, 0);
assert.ok(check('event.watch', { kind: 'disk' }).length > 0, '非法 kind 应被拒');
assert.ok(check('event.watch', { kind: 'net', interval_ms: 100 }).length > 0, 'interval 下限 1s');
assert.ok(check('event.watch', { kind: 'net', interval_ms: 99999 }).length > 0, 'interval 上限 60s');
});
test('event.poll:游标与条数可省略,且不接受未知参数', () => {
assert.equal(check('event.poll', {}).length, 0);
assert.equal(check('event.poll', { since: 12, limit: 100 }).length, 0);
assert.ok(check('event.poll', { limit: 0 }).length > 0, 'limit 下限 1');
assert.ok(check('event.poll', { limit: 999 }).length > 0, 'limit 上限 500');
assert.ok(check('event.poll', { bogus: 1 }).length > 0, '未知参数应被拒');
});
// ---------- 默认值 ----------
test('applyDefaults:新能力默认值正确填充', () => {
const screenFind = applyDefaults({ text: 'x' }, findCapability('screen.find').params_schema);
assert.equal(screenFind.method, 'auto', 'screen.find 默认 auto');
const taskGet = applyDefaults({ task_id: 't' }, findCapability('system.task.get').params_schema);
assert.equal(taskGet.stream, 'stdout');
assert.equal(taskGet.offset, 0);
const exec = applyDefaults({ command: 'x' }, findCapability('system.shell.exec').params_schema);
assert.equal(exec.async, false);
assert.equal(exec.timeout_ms, 30000);
});