## 背景:复盘 §四 那条判断仍成立 代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 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 包构建通过。
100 lines
4.4 KiB
JavaScript
100 lines
4.4 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { tmpdir } from 'node:os';
|
||
import { logQuery, parseLeadingTs } from '../../apps/agent/dist/capabilities/log.js';
|
||
|
||
/**
|
||
* log.query 的纯逻辑测试(v1.8 方向二第一块)。
|
||
* 文件级行为(流式读取/白名单)用真实临时文件验证,无需被控端进程。
|
||
*/
|
||
|
||
const dir = mkdtempSync(join(tmpdir(), 'logquery-'));
|
||
const sample = join(dir, 'app.log');
|
||
writeFileSync(
|
||
sample,
|
||
[
|
||
'[2026-10-07 10:00:01] [INFO] agent started',
|
||
'[2026-10-07 10:00:05] [DEBUG] connecting to relay',
|
||
'[2026-10-07 10:00:09] [ERROR] handshake timeout after 5000ms',
|
||
'[2026-10-07 10:00:12] [WARN] retrying (attempt 2)',
|
||
'[2026-10-07 10:00:15] [INFO] handshake ok',
|
||
'plain line without level',
|
||
'[2026-10-07 10:00:20] [ERROR] proxy unreachable: ECONNREFUSED',
|
||
].join('\n') + '\n',
|
||
'utf8',
|
||
);
|
||
|
||
test('log.query:无过滤时按行号顺序回全部', async () => {
|
||
const r = await logQuery({ path: sample });
|
||
assert.equal(r.matched.length, 7);
|
||
assert.equal(r.matched[0].text.includes('agent started'), true);
|
||
assert.equal(r.matched[0].n, 1, '行号从 1 开始');
|
||
assert.equal(r.scanned, 7);
|
||
});
|
||
|
||
test('log.query:pattern 正则过滤(忽略大小写)', async () => {
|
||
const r = await logQuery({ path: sample, pattern: 'handshake' });
|
||
assert.equal(r.matched.length, 2, 'handshake timeout + handshake ok(pattern 忽略大小写)');
|
||
assert.ok(r.matched.every((m) => /handshake/i.test(m.text)));
|
||
});
|
||
|
||
test('log.query:level 过滤只回该级别(含 WARNING→WARN 归一)', async () => {
|
||
const errs = await logQuery({ path: sample, level: 'ERROR' });
|
||
assert.equal(errs.matched.length, 2);
|
||
assert.ok(errs.matched.every((m) => m.text.includes('[ERROR]')));
|
||
const warns = await logQuery({ path: sample, level: 'WARN' });
|
||
assert.equal(warns.matched.length, 1);
|
||
// 无级别的行不会被 level 过滤命中
|
||
assert.ok(!warns.matched.some((m) => m.text.includes('plain line')));
|
||
});
|
||
|
||
test('log.query:limit 与 offset 分页', async () => {
|
||
const r = await logQuery({ path: sample, limit: 2, offset: 2 });
|
||
assert.equal(r.matched.length, 2);
|
||
assert.equal(r.matched[0].n, 3, 'offset=2 跳过前 2 条命中');
|
||
assert.equal(r.matched[1].n, 4);
|
||
});
|
||
|
||
test('log.query:tail 取末尾 N 条', async () => {
|
||
const r = await logQuery({ path: sample, tail: true, limit: 3 });
|
||
assert.equal(r.matched.length, 3);
|
||
assert.equal(r.matched[2].n, 7, '最后一条是第 7 行');
|
||
assert.ok(r.matched[2].text.includes('ECONNREFUSED'));
|
||
});
|
||
|
||
test('log.query:since 按行首时间戳过滤', async () => {
|
||
const since = Date.parse('2026-10-07T10:00:12');
|
||
const r = await logQuery({ path: sample, since });
|
||
// 10:00:12 及之后共 4 行(12/15/20 + plain 行无时间戳 → 放行)
|
||
assert.equal(r.matched.length, 4);
|
||
assert.ok(!r.matched.some((m) => m.text.includes('agent started')));
|
||
});
|
||
|
||
test('log.query:非法 pattern / level / 路径均被拒', async () => {
|
||
await assert.rejects(() => logQuery({ path: sample, pattern: '[' }), /不是合法正则/);
|
||
await assert.rejects(() => logQuery({ path: sample, level: 'TRACEY' }), /不支持的 level/);
|
||
await assert.rejects(() => logQuery({ path: join(dir, 'nope.log') }), /不存在或不可读/);
|
||
await assert.rejects(() => logQuery({ path: dir }), /不是普通文件/);
|
||
});
|
||
|
||
test('log.query:limit 上限裁剪(5000)', async () => {
|
||
// limit=99999 应被裁剪到 5000,不报错
|
||
const r = await logQuery({ path: sample, limit: 99999 });
|
||
assert.equal(r.matched.length, 7);
|
||
});
|
||
|
||
test('parseLeadingTs:支持 ISO 与方括号形态', () => {
|
||
assert.equal(parseLeadingTs('[2026-10-07 10:00:12] [WARN] x'), Date.parse('2026-10-07T10:00:12'));
|
||
assert.equal(parseLeadingTs('2026-10-07T10:00:12Z hello'), Date.parse('2026-10-07T10:00:12Z'), 'Z 必须按 UTC 解析(曾被吃掉 → 偏 8 小时)');
|
||
assert.equal(parseLeadingTs('2026-10-07T10:00:12+08:00 x'), Date.parse('2026-10-07T10:00:12+08:00'), '带偏移量同理');
|
||
assert.equal(parseLeadingTs('[2026-10-07 10:00:12] x'), Date.parse('2026-10-07T10:00:12'), '无时区 → 本地时间');
|
||
assert.equal(parseLeadingTs('2026/10/07 10:00:12 x'), Date.parse('2026-10-07T10:00:12'));
|
||
assert.equal(parseLeadingTs('no timestamp here'), null);
|
||
});
|
||
|
||
process.on('exit', () => {
|
||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* 忽略 */ }
|
||
});
|