根因:测试脚本用 HOME 指定数据目录,但 Windows 的 os.homedir() 认 USERPROFILE, 导致被控端读不到测试配置、回落到默认端口 8765,与测试连接的 18765 不匹配。 - 引入 NODEAGENT_HOME(数据目录,跨平台一致),agent/client 均优先读取 - 两个 E2E 脚本改用 NODEAGENT_HOME,并同时设置 HOME/USERPROFILE 兜底 - 转发被控端 stdout/stderr,被控端提前退出时立即失败(便于定位) - 就绪等待超时 15s → 30s 本地已回归:13/13 通过
211 lines
7.6 KiB
JavaScript
211 lines
7.6 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|
||
} 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);
|
||
});
|