ktg-plugin-marketplace/plugins/ms-ai-architect/tests/kb-update/test-log-rotate.test.mjs
Kjell Tore Guttormsen aefe9ef5b4 feat(ms-ai-architect): add lib/log-rotate for bounded log disk use [skip-docs]
Foundation lib for v1.12.0 cron rewrite — closes brief deliverable
"log-rotate" that was missing from the original plan (Phase 9 scope
revision). Standard logrotate idiom, zero dependencies.

- rotateLog(logPath, opts) returns {rotated, dropped, kept}
- Defaults: maxSizeBytes 10 MB, maxGenerations 5 (1 active + 4 rotated)
- No-op when log missing or under threshold
- Over-size: drop oldest, shift .N..1 down by one, move active → .1
- maxGenerations=1 keeps only the active slot (no rotated copies)
- Pure stdlib fs.renameSync chain with silent try/catch on missing gens

8/8 tests pass: missing/under-size/over-size paths, chained 6 rotations
capped at maxGenerations, oldest dropped, two-step content shift.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 10:54:50 +02:00

127 lines
4.5 KiB
JavaScript

// tests/kb-update/test-log-rotate.test.mjs
// Unit tests for scripts/kb-update/lib/log-rotate.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
mkdtempSync,
rmSync,
writeFileSync,
readFileSync,
existsSync,
statSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rotateLog } from '../../scripts/kb-update/lib/log-rotate.mjs';
function withTmp(fn) {
const dir = mkdtempSync(join(tmpdir(), 'lr-test-'));
try {
return fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
test('rotateLog — missing log is a no-op', () => {
withTmp((dir) => {
const log = join(dir, 'kb.log');
const result = rotateLog(log, { maxSizeBytes: 100 });
assert.equal(result.rotated, false);
assert.equal(existsSync(log), false);
});
});
test('rotateLog — under-size log is not rotated', () => {
withTmp((dir) => {
const log = join(dir, 'kb.log');
writeFileSync(log, 'tiny', 'utf8');
const before = statSync(log).size;
const result = rotateLog(log, { maxSizeBytes: 1024 });
assert.equal(result.rotated, false);
assert.equal(statSync(log).size, before);
assert.equal(existsSync(`${log}.1`), false);
});
});
test('rotateLog — over-size log moves to .1 with original content', () => {
withTmp((dir) => {
const log = join(dir, 'kb.log');
const content = 'x'.repeat(2048);
writeFileSync(log, content, 'utf8');
const result = rotateLog(log, { maxSizeBytes: 1024 });
assert.equal(result.rotated, true);
assert.equal(existsSync(log), false);
assert.equal(existsSync(`${log}.1`), true);
assert.equal(readFileSync(`${log}.1`, 'utf8'), content);
});
});
test('rotateLog — chained rotation across 6 calls keeps at most maxGenerations files', () => {
withTmp((dir) => {
const log = join(dir, 'kb.log');
const max = 5; // 1 active + 4 rotated; lastGen = 4
for (let i = 0; i < 6; i++) {
writeFileSync(log, `gen-${i}-${'x'.repeat(2048)}`, 'utf8');
rotateLog(log, { maxSizeBytes: 1024, maxGenerations: max });
}
// After 6 over-size rotations, keep .1..(max-1) = .1..4 from the latest
// chain. Active log was just rotated → no active log on disk.
assert.equal(existsSync(log), false);
for (let i = 1; i <= max - 1; i++) {
assert.equal(existsSync(`${log}.${i}`), true, `expected ${log}.${i}`);
}
assert.equal(existsSync(`${log}.${max}`), false);
assert.equal(existsSync(`${log}.${max + 1}`), false);
});
});
test('rotateLog — existing oldest generation is dropped on next rotation', () => {
withTmp((dir) => {
const log = join(dir, 'kb.log');
// Pre-seed an oldest generation so we can prove it gets dropped.
writeFileSync(`${log}.4`, 'oldest-content', 'utf8');
writeFileSync(log, 'x'.repeat(2048), 'utf8');
const result = rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 5 });
assert.equal(result.rotated, true);
// .4 dropped; new .1 created from prior active; nothing at .4 since
// there were no .3/.2 to shift up.
assert.equal(result.dropped, `${log}.4`);
assert.equal(existsSync(`${log}.4`), false);
assert.equal(existsSync(`${log}.1`), true);
});
});
test('rotateLog — maxGenerations=1 keeps only the active slot (no .1)', () => {
withTmp((dir) => {
const log = join(dir, 'kb.log');
writeFileSync(log, 'x'.repeat(2048), 'utf8');
const result = rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 1 });
assert.equal(result.rotated, true);
assert.equal(existsSync(log), false);
assert.equal(existsSync(`${log}.1`), false);
assert.equal(result.dropped, log);
});
});
test('rotateLog — preserves rotated content across two-step shift', () => {
withTmp((dir) => {
const log = join(dir, 'kb.log');
// First rotation
writeFileSync(log, `first-${'x'.repeat(2048)}`, 'utf8');
rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 3 });
assert.equal(readFileSync(`${log}.1`, 'utf8').startsWith('first-'), true);
// Second rotation: prior .1 should shift to .2; new .1 from second active.
writeFileSync(log, `second-${'x'.repeat(2048)}`, 'utf8');
rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 3 });
assert.equal(readFileSync(`${log}.1`, 'utf8').startsWith('second-'), true);
assert.equal(readFileSync(`${log}.2`, 'utf8').startsWith('first-'), true);
});
});
test('rotateLog — empty logPath rejected', () => {
assert.throws(() => rotateLog(''), /logPath is required/);
assert.throws(() => rotateLog(undefined), /logPath is required/);
});