Files
nodeagent/tests/unit/startup-guard.test.mjs
jkooe 419f6dca13 fix(agent): 看门狗日志按 1MB×4 轮换 —— 修常驻被控端的无界增长
startup-guard.log 一直是 appendFileSync 无限追加。看门狗常驻被控端,
「坏版本 + supervisor 反复重启」时写得很快,磁盘写满会连带把 agent 的
审计写入一起拖死(看门狗自己就在同一台机器上)。

- core(零依赖纯函数):shouldRotate(严格大于阈值,NaN 不触发)+ rotationTargets;
- guard:超阈值先丢最旧 .N → 逐个后移 → 当前滚为 .1,滚动与写入均失败静默
  (日志问题绝不能反过来拦住被控端启动);
- 单测 +3:阈值边界(含恰好等于/Nan)、命名序列、真实滚存三轮(验证只留 3 份、
  当前日志重新开始、未超阈值只追加)。
2026-10-27 08:04:20 +08:00

505 lines
23 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 { createHash } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
STARTUP_GRACE_MS,
backupNameRegex,
decideAfterExit,
decideRestore,
hashMatches,
initialState,
nextFailures,
planRestore,
readState,
rotationTargets,
shouldRotate,
zeroFailures,
} from '../../scripts/lib/startup-guard-core.mjs';
import {
appendGuardLog,
hashFileSync,
listBackupCandidates,
parseGuardArgs,
readLkgRecord,
readStateFile,
resolvePort,
restoreFromLkg,
spawnEnv,
statePath,
writeState,
} from '../../scripts/startup-guard.mjs';
// 跨端一致性:守卫与 CLI rollback 必须认同一批备份、同一套指纹规则
import { backupNameRegex as cliBackupNameRegex, hashMatches as cliHashMatches } from '../../apps/cli/dist/cmd/rollback.js';
/**
* 看门狗守卫(决策 R-1 / ARCH A-P0-6)。
*
* 为什么需要守卫:rollback 是 RPC 命令,被控端「完全起不来」时无监听可连。
* 这里钉死看门狗的**判断**不漂移:
* 1. 阈值边界:N-1 不恢复、N 才恢复;
* 2. 有 LKG 且指纹匹配才恢复;无 LKG / 无匹配 → 放行且**不擦除 install-dir**;
* 3. 状态文件损坏按 0(不猜、不拒绝服务);
* 4. 启动期死亡计数、运行期死亡不计数(防计划内重启误触发回滚);
* 5. 成功后归零;
* 6. 恢复日志必须落盘;
* 7. 与 CLI rollback 的备份命名/指纹规则同口径(否则两边认不出同一批文件)。
*/
// ---------- 纯函数:状态机 ----------
test('readState:缺失/空串/损坏 JSON/字段非法一律按 0', () => {
assert.deepEqual(readState(undefined), { failures: 0, updated_at: '' });
assert.deepEqual(readState(''), { failures: 0, updated_at: '' });
assert.deepEqual(readState(' '), { failures: 0, updated_at: '' });
assert.deepEqual(readState('{ not json'), { failures: 0, updated_at: '' });
assert.deepEqual(readState('[]'), { failures: 0, updated_at: '' });
assert.deepEqual(readState('{"failures":"3"}'), { failures: 0, updated_at: '' });
assert.deepEqual(readState('{"failures":-2}'), { failures: 0, updated_at: '' });
assert.deepEqual(readState('{"failures":2.5}'), { failures: 0, updated_at: '' });
assert.deepEqual(readState('{"failures":3,"updated_at":"2026-10-25T00:00:00Z"}'), {
failures: 3,
updated_at: '2026-10-25T00:00:00Z',
});
});
test('readState:CRLF 状态文件同样可解析(Windows 手工编辑场景)', () => {
const raw = '{\r\n "failures": 2,\r\n "updated_at": "t"\r\n}\r\n';
assert.deepEqual(readState(raw), { failures: 2, updated_at: 't' });
});
test('decideRestore:阈值边界 N-1 不恢复、N 恢复', () => {
assert.equal(decideRestore({ failures: 0 }, 3), false);
assert.equal(decideRestore({ failures: 2 }, 3), false, 'N-1 必须不恢复');
assert.equal(decideRestore({ failures: 3 }, 3), true, 'N 必须恢复');
assert.equal(decideRestore({ failures: 9 }, 3), true);
// 阈值非法 → 不恢复(守卫自身配置错误时不许乱动入口文件)
assert.equal(decideRestore({ failures: 99 }, 0), false);
assert.equal(decideRestore({ failures: 99 }, -1), false);
assert.equal(decideRestore({ failures: 99 }, 2.5), false);
assert.equal(decideRestore({ failures: 99 }, 'x'), false);
});
test('nextFailures:递增且带时间戳;zeroFailures 归零', () => {
const s1 = nextFailures({ failures: 0, updated_at: '' }, 'T1');
assert.deepEqual(s1, { failures: 1, updated_at: 'T1' });
const s2 = nextFailures(s1, 'T2');
assert.deepEqual(s2, { failures: 2, updated_at: 'T2' });
assert.deepEqual(zeroFailures('T3'), { failures: 0, updated_at: 'T3' });
});
test('decideAfterExit:启动期死亡(<宽限期)→ 计数 +1', () => {
const d = decideAfterExit({
code: 1, signal: null, livedMs: 800, stopping: false, serviceUp: false,
state: { failures: 1, updated_at: '' }, nowIso: 'T',
});
assert.equal(d.counted, true);
assert.equal(d.persist, true);
assert.equal(d.state.failures, 2);
assert.equal(d.exitCode, 1);
assert.equal(d.reason, 'startup-failure');
});
test('decideAfterExit:运行期死亡(>=宽限期)→ 不计数(计划内重启杀的是长命进程)', () => {
const d = decideAfterExit({
code: null, signal: 'SIGKILL', livedMs: STARTUP_GRACE_MS + 1, stopping: false, serviceUp: false,
state: { failures: 2, updated_at: '' }, nowIso: 'T',
});
assert.equal(d.counted, false);
assert.equal(d.persist, false, '状态没变就不写盘');
assert.equal(d.state.failures, 2, '计数不许被计划内重启搅动');
assert.equal(d.exitCode, 1, '退出码仍交回 supervisor 重启');
assert.equal(d.reason, 'runtime-exit');
});
test('decideAfterExit:服务已被别的实例提供 → 归零退出 0(撞车不算失败)', () => {
const d = decideAfterExit({
code: 1, signal: null, livedMs: 500, stopping: false, serviceUp: true,
state: { failures: 2, updated_at: '' }, nowIso: 'T',
});
assert.equal(d.counted, false);
assert.equal(d.state.failures, 0);
assert.equal(d.exitCode, 0);
assert.equal(d.reason, 'service-up');
});
test('decideAfterExit:本守卫被要求停(systemctl stop)→ 不计数', () => {
const d = decideAfterExit({
code: 1, signal: null, livedMs: 60_000, stopping: true, serviceUp: false,
state: { failures: 1, updated_at: '' }, nowIso: 'T',
});
assert.equal(d.counted, false);
assert.equal(d.persist, false);
assert.equal(d.state.failures, 1);
assert.equal(d.reason, 'stopping');
});
test('decideAfterExit:干净退出 0 → 成功后归零', () => {
const d = decideAfterExit({
code: 0, signal: null, livedMs: 5, stopping: false, serviceUp: false,
state: { failures: 2, updated_at: '' }, nowIso: 'T',
});
assert.equal(d.counted, false);
assert.equal(d.state.failures, 0);
assert.equal(d.exitCode, 0);
assert.equal(d.reason, 'clean-exit');
});
// ---------- 纯函数:备份命名与指纹(含跨端一致性) ----------
test('backupNameRegex:认两套备份命名(deploy 固定名 + selfupdate 时间戳名)', () => {
const re = backupNameRegex('agent.mjs');
assert.ok(re.test('agent.mjs.bak'), 'deploy 固定名');
assert.ok(re.test('agent.mjs.bak-1729300000000'), 'selfupdate 时间戳名');
assert.ok(!re.test('agent.mjs.bakX'), '后缀必须整段匹配');
assert.ok(!re.test('agent.mjs'), '入口自己不是备份');
assert.ok(!re.test('other.mjs.bak'), '别名的备份不认');
const re2 = backupNameRegex('index.js');
assert.ok(re2.test('index.js.bak'));
});
test('跨端一致:守卫与 CLI rollback 的备份命名/指纹规则同口径', () => {
// 两边认不出同一批备份 = 看门狗恢复的文件与 rollback 枚举的文件不一致
const names = ['agent.mjs.bak', 'agent.mjs.bak-123', 'agent.mjs.bakX', 'agent.mjs', 'x.bak', 'a+b.c.bak'];
for (const entry of ['agent.mjs', 'index.js', 'a+b.c']) {
const a = backupNameRegex(entry);
const b = cliBackupNameRegex(entry);
for (const n of names) {
assert.equal(a.test(n), b.test(n), `口径漂移: entry=${entry} name=${n}`);
}
}
const full = createHash('sha256').update('x').digest('hex');
assert.equal(hashMatches(full, full.slice(0, 12)), cliHashMatches(full, full.slice(0, 12)));
assert.equal(hashMatches(full, full), cliHashMatches(full, full));
assert.equal(hashMatches('', 'x'), cliHashMatches('', 'x'));
});
test('hashMatches:全量相等/12 位前缀回落;空值与反向不匹配', () => {
const full = createHash('sha256').update('content').digest('hex');
assert.equal(hashMatches(full, full), true);
assert.equal(hashMatches(full, full.slice(0, 12)), true, '老 LKG 12 位前缀回落');
assert.equal(hashMatches(full.slice(0, 12), full), false, '不得反向匹配');
assert.equal(hashMatches('', full), false);
assert.equal(hashMatches(full, ''), false);
});
test('planRestore:无匹配返回 null;按给定顺序取第一个匹配者', () => {
const good = createHash('sha256').update('good').digest('hex');
const bad = createHash('sha256').update('bad').digest('hex');
assert.equal(planRestore([], good), null);
assert.equal(planRestore([{ path: '/a', hash: bad }], good), null);
assert.deepEqual(planRestore([{ path: '/a', hash: good }], good), { path: '/a', hash: good });
assert.deepEqual(
planRestore([{ path: '/new', hash: bad }, { path: '/old', hash: good }], good),
{ path: '/old', hash: good },
'匹配优先于新近(新备份不是 LKG 时不许被抢)',
);
});
// ---------- 参数解析 ----------
test('parseGuardArgs:-- 之后是完整启动命令;缺值/未知参数报错', () => {
const o = parseGuardArgs([
'--data-dir', '/d', '--lkg-dir', '/l', '--install-dir', '/i',
'--threshold', '3', '--port', '8765', '--entry', 'agent.mjs',
'--', 'node', '/i/agent.mjs',
]);
assert.equal(o.dataDir, '/d');
assert.equal(o.lkgDir, '/l');
assert.equal(o.installDir, '/i');
assert.equal(o.threshold, 3);
assert.equal(o.port, 8765);
assert.equal(o.entry, 'agent.mjs');
assert.deepEqual(o.cmd, ['node', '/i/agent.mjs']);
assert.equal(parseGuardArgs([]).cmd.length, 0, '没有 -- 就没有启动命令');
assert.throws(() => parseGuardArgs(['--data-dir']), /缺少取值/);
assert.throws(() => parseGuardArgs(['--wat']), /未知参数/);
});
test('spawnEnv:强制注入 NODEAGENT_HOME = data-dir,其余环境原样保留', () => {
const env = spawnEnv('/data/na', { PATH: '/usr/bin', NODEAGENT_HOME: '/stale' });
assert.equal(env.NODEAGENT_HOME, '/data/na',
'data-dir 是权威:覆盖陈旧值,防 agentDir() 与守卫 data-dir 漂移(Windows 计划任务不带环境变量)');
assert.equal(env.PATH, '/usr/bin', '其余环境变量原样保留');
const inherit = spawnEnv('/data/na');
assert.equal(inherit.NODEAGENT_HOME, '/data/na', '默认继承当前进程环境');
});
// ---------- IO 集成(真实临时目录) ----------
const GOOD = Buffer.from('GOOD-AGENT-LKG-CONTENT');
const BAD = Buffer.from('BROKEN-AGENT-CONTENT');
const goodHash = createHash('sha256').update(GOOD).digest('hex');
function makeBox() {
const root = mkdtempSync(join(tmpdir(), 'na-guard-'));
const dataDir = join(root, 'data');
const lkgDir = join(root, 'install');
const installDir = lkgDir;
mkdirSync(dataDir, { recursive: true });
mkdirSync(installDir, { recursive: true });
return { root, dataDir, lkgDir, installDir };
}
function writeLkg(dataDir, rec) {
writeFileSync(join(dataDir, 'agent-lkg.json'), `${JSON.stringify(rec, null, 2)}\n`, 'utf8');
}
test('restoreFromLkg:有 LKG 且指纹匹配 → 就地覆写入口 + 内容正确', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length,
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, true);
assert.equal(r.backup, join(lkgDir, 'agent.mjs.bak'));
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), GOOD, '入口必须被还原成 LKG 内容');
});
test('restoreFromLkg:新备份不是 LKG 时不许被抢(匹配优先于新近)', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length,
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
const stale = join(lkgDir, 'agent.mjs.bak-1000');
const fresh = join(lkgDir, 'agent.mjs.bak-2000');
writeFileSync(stale, GOOD);
writeFileSync(fresh, BAD);
// fresh 更新(mtime 更大),但它不是 LKG
const now = Date.now() / 1000;
utimesSync(fresh, now, now);
utimesSync(stale, now - 3600, now - 3600);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, true);
assert.equal(r.backup, stale, '必须命中指纹匹配的旧备份,而不是更新的那个');
});
test('restoreFromLkg:老 LKG 只有 12 位 hash 也能按前缀匹配', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), bytes: GOOD.length, // 无 hash_full(v2.1.0 之前的记录)
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'win32',
});
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, true);
});
test('restoreFromLkg:无 LKG → 放行且 install-dir 一个字节都不动', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const before = readFileSync(join(installDir, 'agent.mjs'));
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, false);
assert.equal(r.reason, 'no-lkg');
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), before, '无 LKG 绝不清空入口');
});
test('restoreFromLkg:有 LKG 但无指纹匹配 → 放行且 install-dir 不动', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: 'aaaaaaaaaaaa', hash_full: createHash('sha256').update('other').digest('hex'),
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, false);
assert.equal(r.reason, 'no-match');
assert.equal(r.candidates.length, 1, '候选指纹要带回供排查');
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), BAD, '无匹配绝不清空入口');
});
test('restoreFromLkg:无备份候选 → no-candidates,不动入口', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), hash_full: goodHash,
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, false);
assert.equal(r.reason, 'no-candidates');
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), BAD);
});
test('restoreFromLkg:目标不可写(异常路径)→ 吞掉返回 error,不抛', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), hash_full: goodHash,
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
mkdirSync(join(installDir, 'agent.mjs')); // 入口是目录 → 写必失败
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, false);
assert.ok(r.reason.startsWith('error:'), '异常必须被吞成 error 返回');
});
test('listBackupCandidates:按 mtime 新→早,只收 <entry>.bak*', () => {
const { lkgDir } = makeBox();
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(lkgDir, 'agent.mjs.bak-1'), GOOD);
writeFileSync(join(lkgDir, 'agent.json'), '{}');
writeFileSync(join(lkgDir, 'other.bak'), GOOD);
const now = Date.now() / 1000;
utimesSync(join(lkgDir, 'agent.mjs.bak'), now - 100, now - 100);
utimesSync(join(lkgDir, 'agent.mjs.bak-1'), now, now);
const cands = listBackupCandidates(lkgDir, 'agent.mjs');
assert.deepEqual(cands.map((c) => c.name), ['agent.mjs.bak-1', 'agent.mjs.bak']);
});
test('hashFileSync:与 createHash 全量一致', () => {
const { lkgDir } = makeBox();
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
assert.equal(hashFileSync(join(lkgDir, 'agent.mjs.bak')), goodHash);
});
// ---------- 状态文件与日志 ----------
test('readStateFile/writeState:落盘可读回;损坏文件按 0', () => {
const { dataDir } = makeBox();
writeState(dataDir, { failures: 2, updated_at: 'T' });
assert.deepEqual(readStateFile(dataDir), { failures: 2, updated_at: 'T' });
assert.ok(existsSync(statePath(dataDir)));
// 损坏 → 0(不猜)
writeFileSync(statePath(dataDir), 'garbage', 'utf8');
assert.deepEqual(readStateFile(dataDir), { failures: 0, updated_at: '' });
// 缺失 → 0
rmSync(statePath(dataDir), { force: true });
assert.deepEqual(readStateFile(dataDir), { failures: 0, updated_at: '' });
});
test('appendGuardLog:JSONL 日志落盘且含关键字段', () => {
const { dataDir } = makeBox();
appendGuardLog(dataDir, { action: 'restore', restored: true, backup: '/x/agent.mjs.bak' });
appendGuardLog(dataDir, { action: 'child-exit', code: 1, counted: true, failures: 2 });
const p = join(dataDir, 'logs', 'startup-guard.log');
assert.ok(existsSync(p), '恢复/退出事件必须留日志');
const lines = readFileSync(p, 'utf8').trim().split('\n');
assert.equal(lines.length, 2);
const first = JSON.parse(lines[0]);
assert.equal(first.action, 'restore');
assert.equal(first.restored, true);
assert.ok(first.ts, '日志必须带时间戳');
});
test('readLkgRecord:损坏/缺字段 → null', () => {
const { dataDir } = makeBox();
assert.equal(readLkgRecord(dataDir), null);
writeFileSync(join(dataDir, 'agent-lkg.json'), 'nope', 'utf8');
assert.equal(readLkgRecord(dataDir), null);
writeFileSync(join(dataDir, 'agent-lkg.json'), JSON.stringify({ bytes: 1 }), 'utf8');
assert.equal(readLkgRecord(dataDir), null, '缺 hash 字段视为无效');
});
test('resolvePort:--port 优先 → agent.json 回落 → 都没有返回 null', () => {
const { dataDir } = makeBox();
assert.equal(resolvePort(dataDir, 9000), 9000);
writeFileSync(join(dataDir, 'agent.json'), JSON.stringify({ port: 8765 }), 'utf8');
assert.equal(resolvePort(dataDir, null), 8765, '未传 --port 时读 agent.json');
assert.equal(resolvePort(dataDir, 9000), 9000, '传了就以参数为准');
rmSync(join(dataDir, 'agent.json'), { force: true });
assert.equal(resolvePort(dataDir, null), null, 'Hub 模式无端口 → 跳过端口判断');
});
// ---------- 端到端小剧场:连续崩溃 → 恢复 → 再起 ----------
test('场景:崩溃三次后第四次守卫执行恢复并归零计数', () => {
const { root, dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length,
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
// 前三次:坏版本启动即死 → 计数 1、2、3(均未达阈值,不恢复)
let state = initialState('T0');
for (let i = 1; i <= 3; i += 1) {
const d = decideAfterExit({
code: 1, signal: null, livedMs: 600, stopping: false, serviceUp: false, state, nowIso: `T${i}`,
});
assert.equal(d.counted, true);
state = d.state;
assert.equal(decideRestore(state, 3), i === 3, `第 ${i} 次后阈值判定`);
}
assert.equal(state.failures, 3);
// 第四次:达到阈值 → 恢复 + 归零
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, true);
state = zeroFailures('T4');
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), GOOD);
assert.equal(state.failures, 0);
// 恢复日志已在盘上
appendGuardLog(dataDir, { action: 'restore', restored: true, backup: r.backup });
assert.ok(readFileSync(join(dataDir, 'logs', 'startup-guard.log'), 'utf8').includes('restore'));
rmSync(root, { recursive: true, force: true });
});
// ===========================================================================
// 日志轮换:看门狗常驻被控端,日志无界增长会把磁盘写满
// ===========================================================================
test('shouldRotate:严格大于阈值才轮换(恰好等于不滚)', () => {
assert.equal(shouldRotate(0), false);
assert.equal(shouldRotate(1024, 1024), false, '恰好等于阈值不轮换,避免边界抖动');
assert.equal(shouldRotate(1025, 1024), true);
assert.equal(shouldRotate(Number.NaN, 1024), false, 'NaN(stat 失败)不得触发轮换');
});
test('rotationTargets:从新到旧的 .1 … .N 命名', () => {
assert.deepEqual(rotationTargets('/x/startup-guard.log', 3), [
'/x/startup-guard.log.1',
'/x/startup-guard.log.2',
'/x/startup-guard.log.3',
]);
});
test('appendGuardLog:超阈值滚存,最旧一份被丢弃,当前日志重新开始', () => {
const root = mkdtempSync(join(tmpdir(), 'na-guard-rotate-'));
const dataDir = join(root, 'data');
const logPath = join(dataDir, 'logs', 'startup-guard.log');
mkdirSync(join(dataDir, 'logs'), { recursive: true }); // 先造出超阈值的大文件,再交给 appendGuardLog 轮换
const maxBytes = 200;
// 三次超阈值写入 → .1 / .2 / .3 依次生成,且每次都留下一条记录
for (let i = 1; i <= 3; i += 1) {
writeFileSync(logPath, `${'x'.repeat(maxBytes + 10)}\n`);
appendGuardLog(dataDir, { action: 'probe', i }, { maxBytes, keep: 3 });
}
assert.ok(existsSync(`${logPath}.1`), '应生成 .1');
assert.ok(existsSync(`${logPath}.2`), '应生成 .2');
assert.ok(existsSync(`${logPath}.3`), '应生成 .3');
// 第四次:.3 被丢弃,.2→.3、.1→.2、当前→.1
writeFileSync(logPath, `${'y'.repeat(maxBytes + 10)}\n`);
appendGuardLog(dataDir, { action: 'probe', i: 4 }, { maxBytes, keep: 3 });
assert.ok(existsSync(`${logPath}.3`), '仍只有 3 份历史');
assert.ok(!existsSync(`${logPath}.4`), '不得无限增长出 .4');
// 当前日志只剩本次一行(可解析 JSON)
const lines = readFileSync(logPath, 'utf8').trim().split('\n');
assert.equal(lines.length, 1);
assert.equal(JSON.parse(lines[0]).i, 4);
// 未超阈值时只追加,不轮换
appendGuardLog(dataDir, { action: 'small' }, { maxBytes, keep: 3 });
assert.equal(readFileSync(logPath, 'utf8').trim().split('\n').length, 2);
rmSync(root, { recursive: true, force: true });
});