Files
nodeagent/tests/unit/log-query.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

100 lines
4.4 KiB
JavaScript
Raw Permalink 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 { 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 { /* 忽略 */ }
});