Files
nodeagent/tests/e2e/run.mjs
T
jkooe 96b02df857 fix(e2e): 修复 Windows 上被控端无法就绪的根因
根因:测试脚本用 HOME 指定数据目录,但 Windows 的 os.homedir() 认 USERPROFILE,
导致被控端读不到测试配置、回落到默认端口 8765,与测试连接的 18765 不匹配。

- 引入 NODEAGENT_HOME(数据目录,跨平台一致),agent/client 均优先读取
- 两个 E2E 脚本改用 NODEAGENT_HOME,并同时设置 HOME/USERPROFILE 兜底
- 转发被控端 stdout/stderr,被控端提前退出时立即失败(便于定位)
- 就绪等待超时 15s → 30s

本地已回归:13/13 通过
2026-09-27 02:36:33 +08:00

260 lines
8.7 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 端到端测试
*
* 在 Mac 上启动一个被控端 Agent(临时配置、独立 HOME),
* 用控制端客户端跑通:握手 → 能力调用 → 异常路径。
*
* 运行:node tests/e2e/run.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 = 18765;
const KEY = 'e2e-test-key-0123456789abcdef0123456789abcdef';
const URL = `ws://127.0.0.1:${PORT}`;
let passed = 0;
let failed = 0;
async function test(name, fn) {
try {
await fn();
console.log(` \u2713 ${name}`);
passed += 1;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` ${err instanceof Error ? err.message : String(err)}`);
failed += 1;
}
}
/** 启动被控端 Agent(独立 HOME,避免污染真实配置)。 */
function startAgent() {
// NODEAGENT_HOME 直接作为数据目录(等价于 ~/.nodeagent)
const dataDir = mkdtempSync(join(tmpdir(), 'nodeagent-e2e-'));
writeFileSync(
join(dataDir, 'agent.json'),
JSON.stringify({
node_id: 'e2e_win',
host: '127.0.0.1',
port: PORT,
tls: false,
key: KEY,
log_level: 'info',
}),
);
const child = spawn(process.execPath, [join(root, 'apps/agent/dist/index.js')], {
// NODEAGENT_HOME 为准(跨平台一致);HOME/USERPROFILE 兜底
// 注:Windows 的 os.homedir() 认 USERPROFILE 不认 HOME,漏设会导致被控端回落到默认端口
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 true;
} catch {
await new Promise((r) => setTimeout(r, 300));
}
}
throw new Error('被控端未在超时内就绪');
}
async function connect(key = KEY) {
const c = new NodeAgentClient({ url: URL, key, clientId: 'e2e_mac' });
await c.connect();
return c;
}
async function main() {
console.log('\nnodeagent 端到端测试\n');
const agent = startAgent();
try {
await waitReady(30_000, agent);
console.log(` 被控端已就绪 ${URL}\n`);
// ---------- 握手 ----------
await test('FR-01 正确密钥可完成握手并拿到能力清单', async () => {
const c = await connect();
const caps = c.listCapabilities();
assert.equal(caps.length, 7, `期望 7 项能力,实际 ${caps.length}`);
assert.ok(caps.some((x) => x.name === 'system.shell.exec'));
c.close();
});
await test('FR-01 错误密钥被拒绝(E_AUTH_FAILED)', async () => {
await assert.rejects(
() => connect('wrong-key'),
(err) => {
assert.equal(err.code, -32401, `期望 -32401,实际 ${err.code}`);
return true;
},
);
});
// ---------- 能力调用 ----------
await test('FR-04 system.info 返回系统信息', async () => {
const c = await connect();
const r = await c.invoke('system.info');
c.close();
assert.equal(r.status, 'ok');
const d = r.data;
assert.ok(d.hostname, 'hostname 缺失');
assert.ok(typeof d.cpu_cores === 'number');
});
await test('FR-04 system.status 返回 CPU/内存/磁盘/网络', async () => {
const c = await connect();
const r = await c.invoke('system.status');
c.close();
assert.equal(r.status, 'ok');
const d = r.data;
assert.ok(typeof d.cpu_pct === 'number', 'cpu_pct 缺失');
assert.ok(d.memory_total > 0, 'memory_total 无效');
assert.ok(Array.isArray(d.disks) && d.disks.length > 0, 'disks 为空');
assert.ok(Array.isArray(d.net));
});
await test('FR-02 system.shell.exec 执行命令并返回退出码', async () => {
const c = await connect();
const r = await c.invoke('system.shell.exec', { command: 'echo nodeagent-ok' });
c.close();
assert.equal(r.status, 'ok');
assert.equal(r.data.exit_code, 0);
assert.match(r.data.stdout, /nodeagent-ok/);
});
await test('FR-02 system.shell.exec 非零退出码正确回传', async () => {
const c = await connect();
const r = await c.invoke('system.shell.exec', { command: 'exit 3' });
c.close();
assert.equal(r.status, 'ok');
assert.equal(r.data.exit_code, 3);
});
await test('system.process.list 返回进程列表', async () => {
const c = await connect();
const r = await c.invoke('system.process.list', { limit: 5 });
c.close();
assert.equal(r.status, 'ok');
assert.ok(Array.isArray(r.data.processes));
assert.ok(r.data.processes.length > 0);
});
// ---------- 异常路径 ----------
await test('超时命令被强制终止(killed=true)', async () => {
const c = await connect();
const r = await c.invoke('system.shell.exec', { command: 'sleep 30', timeout_ms: 1000 }, 20_000);
c.close();
assert.equal(r.status, 'ok');
assert.equal(r.data.killed, true, '未标记 killed');
assert.equal(r.data.exit_code, 124);
});
await test('未知能力返回 E_CAPABILITY_NOT_FOUND', async () => {
const c = await connect();
await assert.rejects(
() => c.invoke('no.such.capability'),
(err) => {
assert.equal(err.code, -32403);
return true;
},
);
c.close();
});
await test('参数校验失败返回 E_PARAM_INVALID', async () => {
const c = await connect();
await assert.rejects(
() => c.invoke('system.shell.exec', {}),
(err) => {
assert.equal(err.code, -32602);
return true;
},
);
c.close();
});
await test('多传参数被拒绝(additionalProperties: false)', async () => {
const c = await connect();
await assert.rejects(
() => c.invoke('system.info', { bogus: 1 }),
(err) => {
assert.equal(err.code, -32602);
return true;
},
);
c.close();
});
await test('平台相关能力:Windows 成功 / 非 Windows 报 E_UNSUPPORTED_PLATFORM', async () => {
const c = await connect();
const r = await c.invoke('system.service.list');
c.close();
if (process.platform === 'win32') {
assert.equal(r.status, 'ok', 'Windows 上 service.list 应成功');
assert.ok(Array.isArray(r.data.services), 'services 应为数组');
assert.ok(r.data.services.length > 0, 'Windows 上应有服务');
} else {
assert.equal(r.status, 'failed');
assert.equal(r.error.name, 'E_UNSUPPORTED_PLATFORM');
}
});
await test('未认证调用返回 E_AUTH_REQUIRED', async () => {
// 跳过握手,直连后立即 invoke
const ws = new WebSocket(URL);
await new Promise((res, rej) => {
ws.addEventListener('open', () => res());
ws.addEventListener('error', () => rej(new Error('websocket error')));
});
const resp = await new Promise((resolve) => {
ws.addEventListener('message', (ev) => resolve(JSON.parse(ev.data)));
ws.send(
JSON.stringify({ jsonrpc: '2.0', id: '01TEST', method: 'invoke', params: { capability: 'system.info' } }),
);
});
ws.close();
assert.equal(resp.error.code, -32402);
});
} finally {
agent.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 300));
}
console.log(`\n结果: ${passed} 通过, ${failed} 失败\n`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error('测试异常:', err);
process.exit(1);
});