Files
nodeagent/tests/unit/monitor.test.mjs
T
jkooe 7c6a68268b feat(v1.8): 状态采样 —— log.query 被控端侧过滤 + monitor 定时采样落盘与回看(方向二)
## 背景:复盘 §四 那条判断仍成立
代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 system.status 只能看当下快照;
event.watch 解决「有事件时通知」,没解决「**持续记录并回看**」。

> 顺带纠正路线图一处过时判断:"fs.watch 未做" 不成立 —— event.watch 的 kind=file
> 就是 fs.watch 实现(递归 + glob + 推送/拉取双出口)。本轮不重复造。

## ① log.query(新增能力,低危只读)
在被控端侧过滤,只回匹配行 —— 日志常几万行,整份拉回既慢又占带宽。
- 流式逐行读(readline),扫到 limit 即停,不全量载入
- pattern(正则)/ level(含 WARNING→WARN 归一)/ since(按行首时间戳)/ offset / limit / tail
- 单行 >4KB 截断后再匹配;单次扫描上限 200 万行防失控
- 路径复用 fs.guardPath —— **不新开安全面**(fs_roots 非空即受限)

## ② monitor.*(四项新能力,低危)
start / report / stop / list / delete。四类采样源:
- port(TCP 连通性 + 延迟)/ process(存活 + PID)/ command(退出码 + 首行)/ metric(CPU·内存)
- 样本落 <数据目录>/monitors/<id>.jsonl(一行一针),**摘要直接给结论**:
  「断了几次 + 最长连续中断样本数」「CPU/内存 min·avg·max」「成功失败数与退出码集合」
- 并发上限 8;单文件 10 万行封顶轮转;timer.unref() 不阻止 agent 退出

## 真机验证(本地,15 例全过)
port 采样造一次真实断线(关端口 1.6s 再开)→ report 给出 up=9/down=4/最长中断 4 针/延迟 avg 1.31ms;
metric 给 CPU 16.9% 内存 50.24%;落盘 13 样本可回看;stop 后 report 仍可查;
log.query 按级别/pattern+tail 过滤准确;fs_roots 白名单外的 /etc/hosts 被拒;
非法 source/target/id 字符/重复 id/不存在 id 均被拒。

## 修复一个真 bug(测试抓到)
parseLeadingTs 的正则把 ISO 的  时区标记吃掉了 —— UTC 时间被当本地时间解析,
**整体偏 8 小时**。跨时区日志判断"何时断的"会错。已保留 Z/±hh:mm 后缀。

## CLI / MCP
- nodeagent monitor start|report|stop|list|delete ...;nodeagent log --path ... --level ERROR
- MCP:na_monitor(action 分发)+ na_log

## 数据
能力数 43 → **49**;单测 231 → **247** 全绿;e2e 30 全绿;7 包构建通过。
2026-10-09 23:08:03 +08:00

74 lines
3.5 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 { findCapability, validate, CapabilityNames } from '../../packages/protocol/dist/index.js';
/**
* monitor.* 的契约层测试(v1.8 方向二第二块)。
* 运行时行为(真采样/落盘/摘要)在本地真机探针里验证:port 探测 + metric 采样 +
* report 摘要 + stop 落盘。
*/
function check(capName, args) {
return validate(args, findCapability(capName).params_schema);
}
test('monitor 五项能力均已登记且为低危只读/本地', () => {
for (const n of [CapabilityNames.MonitorStart, CapabilityNames.MonitorReport, CapabilityNames.MonitorStop, CapabilityNames.MonitorList, CapabilityNames.MonitorDelete, CapabilityNames.LogQuery]) {
const cap = findCapability(n);
assert.ok(cap, `缺少 ${n}`);
assert.equal(cap.risk, 'low', `${n} 应为 low risk`);
}
});
test('monitor.start:四类 source 通过,非法 source 被拒', () => {
for (const s of ['port', 'process', 'command', 'metric']) {
assert.equal(check('monitor.start', { source: s, target: 'x:1' }).length, 0, `${s} 应通过`);
}
assert.ok(check('monitor.start', { source: 'bogus' }).length > 0, '非法 source 应拒');
assert.ok(check('monitor.start', {}).length > 0, '缺 source 应拒');
});
test('monitor.start:interval_ms 有上下限(500~600000)', () => {
assert.equal(check('monitor.start', { source: 'metric', interval_ms: 500 }).length, 0);
assert.equal(check('monitor.start', { source: 'metric', interval_ms: 600000 }).length, 0);
assert.ok(check('monitor.start', { source: 'metric', interval_ms: 10 }).length > 0, '过小应拒');
assert.ok(check('monitor.start', { source: 'metric', interval_ms: 10 ** 7 }).length > 0, '过大应拒');
});
test('monitor.report/stop/delete:id 必填', () => {
for (const n of ['monitor.report', 'monitor.stop', 'monitor.delete']) {
assert.ok(check(n, {}).length > 0, `${n} 缺 id 应拒`);
assert.equal(check(n, { id: 'm1' }).length, 0, `${n} 带 id 应通过`);
}
assert.equal(check('monitor.list', {}).length, 0, 'monitor.list 无参数');
});
test('log.query:path 必填,limit 上限 5000', () => {
assert.ok(check('log.query', {}).length > 0, '缺 path 应拒');
assert.equal(check('log.query', { path: '/var/log/x.log' }).length, 0);
assert.equal(check('log.query', { path: 'x', limit: 5000 }).length, 0);
assert.ok(check('log.query', { path: 'x', limit: 99999 }).length > 0, '超上限应拒');
assert.ok(check('log.query', { path: 'x', level: 'FATAL' }).length > 0, '非法 level 应拒');
});
test('摘要算法:port 断线次数与最长中断(与 monitor.ts 的 summarize 同步)', () => {
// 复刻 summarize 的核心逻辑,锁住"最长连续中断"的语义(最容易写错的一处)
const samples = [
{ up: true }, { up: false }, { up: false }, { up: false },
{ up: true }, { up: false }, { up: true },
];
let run = 0, worst = 0, downs = 0;
for (const s of samples) {
if (s.up === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); downs += 1; }
}
assert.equal(downs, 4, 'down 计数');
assert.equal(worst, 3, '最长连续中断应为 3 个样本(中间那段)');
assert.equal(samples.filter((s) => s.up).length, 3, 'up 计数');
});
test('摘要算法:command 的成功/失败与出现过的退出码', () => {
const codes = [0, 0, 1, 0, 127];
assert.equal(codes.filter((c) => c === 0).length, 3);
assert.deepEqual([...new Set(codes)], [0, 1, 127]);
});