Files
nodeagent/tests/e2e/windows.mjs
T
jkooe 792937c6b0 feat(v2): 图形接管 —— 屏幕感知 + 输入控制
新增 7 项能力(总计 14):
- screen.info / screen.capture:显示器信息、截屏(region/scale/format)
- input.mouse.move/click/scroll、input.key.type/press:鼠标键盘控制

安全设计:
- input.* 为高危能力,由被控端 allow_input 开关统一管控,默认禁用
- 未开启时返回 E_CAPABILITY_DISABLED(附开启指引)
- 文本输入经 Base64 传参、PowerShell 侧解码,杜绝内容注入
- 按键名走白名单映射(虚拟键码),非法键名直接拒绝

实现:纯 PowerShell(System.Drawing 截屏 + user32.dll SendInput),
无原生编译、无第三方依赖。

接入:
- CLI: screen / screenshot / mouse / key 命令
- MCP: na_screenshot(直接返回图像)/ na_mouse / na_key
- install.ps1: 新增 -AllowInput 开关
- verify.mjs: 新增 v2.1~v2.3 验收项

验证:本地 E2E 16/16;远程验收 12 通过 / 0 失败
2026-09-27 03:43:10 +08:00

239 lines
8.8 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.
/**
* nodeagent Windows 专属能力 E2E
*
* 仅在 Windows 上运行:验证 system.service.list / app.list / app.install(winget)等
* 只在 Windows 被控端可用的能力。
*
* 运行:node tests/e2e/windows.mjs
*/
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';
import { NodeAgentClient } from '../../packages/client/dist/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '../..');
const PORT = 18768;
const KEY = 'windows-e2e-key-0123456789abcdef0123456789abcdef';
const URL = `ws://127.0.0.1:${PORT}`;
let passed = 0;
let failed = 0;
let skipped = 0;
async function test(name, fn) {
try {
const r = await fn();
if (r === 'skip') {
console.log(` - ${name} [skip]`);
skipped += 1;
} else {
console.log(` \u2713 ${name}`);
passed += 1;
}
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` ${err instanceof Error ? err.message : String(err)}`);
failed += 1;
}
}
function startAgent() {
// NODEAGENT_HOME 直接作为数据目录(等价于 ~/.nodeagent)
const dataDir = mkdtempSync(join(tmpdir(), 'nodeagent-win-e2e-'));
writeFileSync(
join(dataDir, 'agent.json'),
JSON.stringify({ node_id: 'win_e2e', host: '127.0.0.1', port: PORT, tls: false, key: KEY, log_level: 'warn' }),
);
const child = spawn(process.execPath, [join(root, 'apps/agent/dist/index.js')], {
// NODEAGENT_HOME 为准(跨平台一致);HOME/USERPROFILE 兜底
env: { ...process.env, NODEAGENT_HOME: dataDir, HOME: dataDir, USERPROFILE: dataDir },
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (d) => process.stdout.write(`[agent] ${d}`));
child.stderr.on('data', (d) => process.stderr.write(`[agent:err] ${d}`));
child.on('error', (err) => console.error(`\n⚠️ [agent spawn 失败] ${err.message}\n`));
child.on('exit', (code, signal) => {
console.log(`\n⚠️ [agent 进程退出] code=${code} signal=${signal}\n`);
});
return child;
}
async function waitReady(timeoutMs = 30_000, child = null) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (child && child.exitCode !== null) {
throw new Error(`被控端进程已退出 (code=${child.exitCode}),请查看上方 agent 输出`);
}
try {
const c = new NodeAgentClient({ url: URL, key: KEY, clientId: 'probe' });
await c.connect();
c.close();
return;
} catch {
await new Promise((r) => setTimeout(r, 300));
}
}
throw new Error('被控端未在超时内就绪');
}
async function connect() {
const c = new NodeAgentClient({ url: URL, key: KEY, clientId: 'win_e2e_mac' });
await c.connect();
return c;
}
async function main() {
if (process.platform !== 'win32') {
console.log('\n非 Windows 平台,跳过 Windows 专属测试。');
process.exit(0);
}
console.log('\nnodeagent Windows 专属能力 E2E\n');
const agent = startAgent();
try {
await waitReady(30_000, agent);
console.log(` 被控端已就绪 ${URL}\n`);
await test('system.info 报告 Windows 平台', async () => {
const c = await connect();
const r = await c.invoke('system.info');
c.close();
assert.equal(r.status, 'ok');
assert.match(String(r.data.os), /Windows/i, `os 字段异常: ${r.data.os}`);
assert.ok(r.data.cpu_cores > 0);
assert.ok(r.data.memory_total > 0);
});
await test('system.status 返回 Windows 磁盘(盘符)', async () => {
const c = await connect();
const r = await c.invoke('system.status');
c.close();
assert.equal(r.status, 'ok');
assert.ok(Array.isArray(r.data.disks) && r.data.disks.length > 0, 'disks 为空');
assert.match(String(r.data.disks[0].drive), /^[A-Z]:/i, `盘符格式异常: ${r.data.disks[0].drive}`);
});
await test('system.service.list 返回 Windows 服务', async () => {
const c = await connect();
const r = await c.invoke('system.service.list', { limit: 50 });
c.close();
assert.equal(r.status, 'ok');
assert.ok(r.data.services.length > 0, '服务列表为空');
const s = r.data.services[0];
assert.ok(s.name && s.state, `服务字段异常: ${JSON.stringify(s)}`);
});
await test('system.service.list 支持按名称筛选', async () => {
const c = await connect();
const r = await c.invoke('system.service.list', { filter: { name_pattern: 'win' } });
c.close();
assert.equal(r.status, 'ok');
assert.ok(r.data.services.every((s) => /win/i.test(s.name) || /win/i.test(s.display_name)));
});
await test('system.process.list 返回进程(含 CPU 百分比)', async () => {
const c = await connect();
const r = await c.invoke('system.process.list', { limit: 10 });
c.close();
assert.equal(r.status, 'ok');
assert.ok(r.data.processes.length > 0, '进程列表为空');
assert.equal(typeof r.data.processes[0].cpu_pct, 'number');
});
await test('system.shell.exec 执行 PowerShell 命令', async () => {
const c = await connect();
const r = await c.invoke('system.shell.exec', { command: 'Get-Date -Format "yyyy-MM-dd"', shell: 'powershell' });
c.close();
assert.equal(r.status, 'ok');
assert.equal(r.data.exit_code, 0);
assert.match(r.data.stdout, /\d{4}-\d{2}-\d{2}/);
});
await test('system.shell.exec 支持 cmd 外壳', async () => {
const c = await connect();
const r = await c.invoke('system.shell.exec', { command: 'echo cmd-ok', shell: 'cmd' });
c.close();
assert.equal(r.status, 'ok');
assert.equal(r.data.exit_code, 0);
assert.match(r.data.stdout, /cmd-ok/);
});
await test('app.list 返回已安装软件(注册表)', async () => {
const c = await connect();
const r = await c.invoke('app.list');
c.close();
assert.equal(r.status, 'ok');
assert.ok(Array.isArray(r.data.apps), 'apps 应为数组');
assert.ok(r.data.apps.length > 0, '未读到任何已安装软件');
});
await test('app.install 用 winget 安装软件', async () => {
const c = await connect();
const probe = await c.invoke('system.shell.exec', { command: 'winget --version' });
if (probe.status !== 'ok' || probe.data.exit_code !== 0) {
c.close();
return 'skip'; // CI 环境无 winget
}
const r = await c.invoke('app.install', { package: 'jqlang.jq', timeout_ms: 600_000 }, 660_000);
c.close();
assert.equal(r.status, 'ok', `app.install 协议层失败: ${JSON.stringify(r.error)}`);
assert.equal(r.data.installed, true, `winget 安装失败:\n${r.data.detail}`);
});
await test('安装后可在 app.list 中检索到', async () => {
const c = await connect();
const r = await c.invoke('app.list', { filter: { name_pattern: 'jq' } });
c.close();
assert.equal(r.status, 'ok');
// jq 由 winget 通过 MSI 安装,注册表中应可见;找不到则视为跳过(安装方式差异)
if (!r.data.apps.some((a) => /jq/i.test(a.name))) return 'skip';
assert.ok(true);
});
// ---------- v2 图形接管(Windows 真机) ----------
await test('v2 screen.info 返回 Windows 显示器', async () => {
const c = await connect();
const r = await c.invoke('screen.info');
c.close();
assert.equal(r.status, 'ok');
assert.ok(r.data.displays.length > 0, '应有显示器');
assert.ok(r.data.displays[0].width > 0, '宽度应大于 0');
});
await test('v2 screen.capture 真实截屏返回图片', async () => {
const c = await connect();
const r = await c.invoke('screen.capture', { scale: 0.5, format: 'jpeg' }, 60_000);
c.close();
assert.equal(r.status, 'ok', `截屏失败: ${JSON.stringify(r.error)}`);
assert.ok(typeof r.data.image === 'string' && r.data.image.length > 1000, '图片数据过小');
assert.ok(r.data.width > 0 && r.data.height > 0, '尺寸无效');
return `${r.data.width}x${r.data.height} ${(r.data.bytes / 1024).toFixed(1)}KB`;
});
await test('v2 输入控制默认禁用(E_CAPABILITY_DISABLED)', async () => {
const c = await connect();
const r = await c.invoke('input.mouse.move', { x: 1, y: 1 });
c.close();
assert.equal(r.status, 'failed');
assert.equal(r.error.name, 'E_CAPABILITY_DISABLED');
});
} finally {
agent.kill();
await new Promise((r) => setTimeout(r, 300));
}
console.log(`\n结果: ${passed} 通过, ${failed} 失败, ${skipped} 跳过\n`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error('测试异常:', err);
process.exit(1);
});