Files
nodeagent/tests/unit/capabilities.test.mjs
T
jkooe ad88e0bb87 feat(C): 操作面补完 —— 拖拽 / 录屏 / 剪贴板图片 / 多设备并发(能力 29 -> 31)
## C1 鼠标拖拽(input.mouse.drag)
- C# SendInput 新增 Drag():按下 → 分步插值移动 → 抬起,整段在一次进程内完成
  (避免每步一个 PS 进程的抖动),步数按距离自适应(8~60),步间延时默认 12ms
- CLI: nodeagent mouse drag <x1> <y1> <x2> <y2>;MCP: na_mouse action=drag
- 同时给 key.press 增加 interval_ms(按键序列间隔可调)
- 真机端到端验证:记事本输入 DRAGTEST_1234 -> 拖选 -> Ctrl+C
  -> 剪贴板由 'CLIPBOARD_EMPTY_MARKER' 变为 'DRAGTEST_1234'(证明拖拽真实生效)

## C2 录屏(screen.record)
- 帧循环在一次 PS 调用内完成;有 ffmpeg 则自动封装 mp4
- 产物留在被控端目录,由调用方 fs.read / pull 取回(不把大视频塞进单条 RPC 响应)
- CLI: nodeagent record --duration/--fps/--scale/--region;MCP: na_record
- 真机验证:3s@2fps 区域录制 -> 6 帧(3087ms),pull 回帧并目检内容正确

## C3 剪贴板图片(clip.get/set 1.1)
- format=auto/text/image;图片经临时文件双向传递(规避 Windows 命令行 ~32KB 上限与 stdout 截断)
- CLI: clip --image-file <路径> / clip --out <路径>;MCP: na_clip format/image_base64
- 真机验证:写入 14592 字节 -> 读回 PNG 400x200,目检图像正确

## C5 多设备并发(fanout)
- nodeagent fanout <能力> [--nodes a,b] [--args JSON]:allSettled 并发,任一失败不影响其他
- 真机验证:并发下发 system.info / system.status 正常

单测 59 -> 61、e2e 21/21 全绿
注:C4(macOS window.*/screen.find 对齐)暂缓,需 macOS 辅助功能/录屏权限,留待 Mac 作被控端时实现
2026-09-29 11:41:48 +08:00

147 lines
6.1 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 {
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',
];
for (const name of expected) {
assert.ok(findCapability(name), `缺少 ${name}`);
}
assert.equal(CAPABILITY_MANIFEST.length, 31, '当前应有 31 项能力');
});
// ---------- 参数校验(新能力) ----------
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('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);
});