Files
SnowLuma/packages/core/tests/log-file-transport.test.ts
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

473 lines
17 KiB
TypeScript

// Tests for the file transport that backs the SnowLuma logger.
// Each test gets its own tmp dir and a fresh FileTransport so env / state
// from one test never bleeds into another.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { FileTransport } from '@snowluma/common/log-file-transport';
let tmpDir: string;
const ENV_KEYS = [
'SNOWLUMA_LOG_FILE',
'SNOWLUMA_LOG_DIR',
'SNOWLUMA_LOG_MAX_MB',
'SNOWLUMA_LOG_MAX_TOTAL_MB',
'SNOWLUMA_LOG_RETAIN_DAYS',
'SNOWLUMA_LOG_PER_UIN',
] as const;
const savedEnv: Record<string, string | undefined> = {};
function setEnv(env: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>): void {
for (const [k, v] of Object.entries(env)) {
if (v === undefined) {
delete process.env[k];
} else {
process.env[k] = v;
}
}
}
function listLogFiles(dir: string): string[] {
return fs
.readdirSync(dir)
.filter((f) => f.endsWith('.log'))
.sort();
}
beforeEach(() => {
for (const k of ENV_KEYS) {
savedEnv[k] = process.env[k];
}
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-log-'));
setEnv({
SNOWLUMA_LOG_DIR: tmpDir,
SNOWLUMA_LOG_FILE: '1',
SNOWLUMA_LOG_MAX_MB: '1',
SNOWLUMA_LOG_MAX_TOTAL_MB: '2',
SNOWLUMA_LOG_RETAIN_DAYS: '7',
SNOWLUMA_LOG_PER_UIN: '0',
});
});
afterEach(() => {
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) {
delete process.env[k];
} else {
process.env[k] = savedEnv[k];
}
}
fs.rmSync(tmpDir, { recursive: true, force: true });
vi.useRealTimers();
});
describe('FileTransport', () => {
it("creates today's file and writes a single line", async () => {
const t = new FileTransport();
t.write('hello world');
await t.close();
const files = listLogFiles(tmpDir);
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^snowluma-\d{4}-\d{2}-\d{2}\.log$/);
expect(fs.readFileSync(path.join(tmpDir, files[0]!), 'utf8')).toBe('hello world\n');
});
it('strips ANSI color escapes and ASCII control chars before writing', async () => {
const t = new FileTransport();
t.write('\x1b[36mINFO\x1b[0m \x07[Bridge] msg');
await t.close();
const [file] = listLogFiles(tmpDir);
expect(fs.readFileSync(path.join(tmpDir, file!), 'utf8')).toBe('INFO [Bridge] msg\n');
});
it('rolls into a .1 file when the current file exceeds the size cap', async () => {
// 1MB cap from beforeEach. Write ~2 MB to force a single rotation.
const t = new FileTransport();
const oneKB = 'x'.repeat(1023); // +1 byte newline = 1024
for (let i = 0; i < 2048; i++) {
t.write(oneKB);
}
await t.close();
const files = listLogFiles(tmpDir);
expect(files.length).toBeGreaterThanOrEqual(2);
const base = files.find((f) => /snowluma-\d{4}-\d{2}-\d{2}\.log$/.test(f));
const split = files.find((f) => /snowluma-\d{4}-\d{2}-\d{2}\.1\.log$/.test(f));
expect(base).toBeDefined();
expect(split).toBeDefined();
});
it('keeps the whole managed log tree within the aggregate byte limit', async () => {
const t = new FileTransport();
const oneKB = 'x'.repeat(1023); // +1 byte newline = 1024
for (let i = 0; i < 4096; i++) {
t.write(oneKB);
}
await t.close();
const totalBytes = listLogFiles(tmpDir)
.map((name) => fs.statSync(path.join(tmpDir, name)).size)
.reduce((sum, bytes) => sum + bytes, 0);
expect(totalBytes).toBeLessThanOrEqual(2 * 1024 * 1024);
});
it('enters one observable degraded state instead of retrying every dropped line', async () => {
setEnv({ SNOWLUMA_LOG_MAX_TOTAL_MB: '1' });
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const t = new FileTransport();
const oneKB = 'x'.repeat(1023);
for (let i = 0; i < 1024; i++) {
t.write(oneKB);
}
for (let i = 0; i < 10; i++) {
t.write('dropped');
}
expect(t.getStorageStatus()).toMatchObject({
state: 'degraded',
maxTotalBytes: 1024 * 1024,
droppedLines: 10,
});
const quotaErrors = stderr.mock.calls
.map(([message]) => String(message))
.filter((message) => message.includes('no closed log can be reclaimed'));
expect(quotaErrors).toHaveLength(1);
await t.close();
stderr.mockRestore();
});
it('opens a new file when the day rolls over mid-process', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 4, 18, 23, 59, 0));
const t = new FileTransport();
t.write('day-one line');
vi.setSystemTime(new Date(2026, 4, 19, 0, 0, 1));
t.write('day-two line');
await t.close();
const files = listLogFiles(tmpDir);
expect(files).toContain('snowluma-2026-05-18.log');
expect(files).toContain('snowluma-2026-05-19.log');
expect(fs.readFileSync(path.join(tmpDir, 'snowluma-2026-05-18.log'), 'utf8')).toContain(
'day-one line',
);
expect(fs.readFileSync(path.join(tmpDir, 'snowluma-2026-05-19.log'), 'utf8')).toContain(
'day-two line',
);
});
it('deletes files older than the retention window on startup', async () => {
const stalePath = path.join(tmpDir, 'snowluma-2020-01-01.log');
fs.writeFileSync(stalePath, 'stale\n');
const t = new FileTransport();
t.write('fresh');
await t.close();
expect(fs.existsSync(stalePath)).toBe(false);
});
it('runs retention again after a writer closes during rotation or shutdown', async () => {
const t = new FileTransport();
t.write('open the active writer');
const stalePath = path.join(tmpDir, 'snowluma-2020-01-01.log');
fs.writeFileSync(stalePath, 'stale\n');
await t.close();
expect(fs.existsSync(stalePath)).toBe(false);
});
it('retainDays=0 disables date cleanup while keeping the byte limit active', async () => {
setEnv({ SNOWLUMA_LOG_RETAIN_DAYS: '0' });
const stalePath = path.join(tmpDir, 'snowluma-2020-01-01.log');
fs.writeFileSync(stalePath, 'stale\n');
const t = new FileTransport();
await t.close();
expect(fs.existsSync(stalePath)).toBe(true);
expect(t.getStorageStatus().maxTotalBytes).toBe(2 * 1024 * 1024);
});
it('never deletes an active log when a retention update makes its date stale', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 1, 12, 0, 0));
const activePath = path.join(tmpDir, 'snowluma-2026-01-01.log');
fs.writeFileSync(activePath, 'existing\n');
const t = new FileTransport();
t.write('still active');
expect(t.currentPath).toBe(activePath);
vi.setSystemTime(new Date(2026, 0, 3, 12, 0, 0));
await t.updatePolicy({
maxTotalMb: 2,
retainDays: 1,
perUinEnabled: false,
});
expect(fs.existsSync(activePath)).toBe(true);
expect(t.getStorageStatus().activeFileCount).toBe(1);
await t.close();
});
it('applies a lower aggregate limit immediately across shared and account logs', async () => {
setEnv({
SNOWLUMA_LOG_MAX_TOTAL_MB: '4',
SNOWLUMA_LOG_RETAIN_DAYS: '0',
});
const oldPath = path.join(tmpDir, 'snowluma-2026-07-24.log');
const accountDir = path.join(tmpDir, '12345');
const newerPath = path.join(accountDir, 'snowluma-2026-07-24.log');
fs.mkdirSync(accountDir);
fs.writeFileSync(oldPath, Buffer.alloc(700 * 1024));
fs.writeFileSync(newerPath, Buffer.alloc(700 * 1024));
const oldTime = new Date('2026-07-24T01:00:00Z');
const newTime = new Date('2026-07-24T02:00:00Z');
fs.utimesSync(oldPath, oldTime, oldTime);
fs.utimesSync(newerPath, newTime, newTime);
const t = new FileTransport();
const status = await t.updatePolicy({
maxTotalMb: 1,
retainDays: 0,
perUinEnabled: false,
});
expect(fs.existsSync(oldPath)).toBe(false);
expect(fs.existsSync(newerPath)).toBe(true);
expect(status).toMatchObject({
state: 'healthy',
maxTotalBytes: 1024 * 1024,
totalBytes: 700 * 1024,
});
await t.close();
});
it('disables per-account writer creation before awaiting old writer shutdown', async () => {
setEnv({ SNOWLUMA_LOG_PER_UIN: '1' });
const t = new FileTransport();
t.write('before policy update', 12345);
expect(t.perUinPath(12345)).not.toBeNull();
const updating = t.updatePolicy({
maxTotalMb: 2,
retainDays: 7,
perUinEnabled: false,
});
t.write('during policy update', 12345);
expect(t.perUinPath(12345)).toBeNull();
await updating;
expect(t.getStorageStatus().perUinEnabled).toBe(false);
await t.close();
});
it('rejects policies whose unit conversion would exceed safe integer bytes', async () => {
const t = new FileTransport();
await expect(
t.updatePolicy({
maxTotalMb: Number.MAX_SAFE_INTEGER,
retainDays: 7,
perUinEnabled: false,
}),
).rejects.toThrow(/maxTotalMb/);
await expect(
t.updatePolicy({
maxTotalMb: 2,
retainDays: Number.MAX_SAFE_INTEGER,
perUinEnabled: false,
}),
).rejects.toThrow(/retainDays/);
await t.close();
});
it('fails fast on invalid direct log-storage environment values', () => {
setEnv({ SNOWLUMA_LOG_MAX_MB: '0' });
expect(() => new FileTransport()).toThrow(/SNOWLUMA_LOG_MAX_MB/);
setEnv({ SNOWLUMA_LOG_MAX_MB: '1' });
setEnv({ SNOWLUMA_LOG_MAX_TOTAL_MB: '0' });
expect(() => new FileTransport()).toThrow(/SNOWLUMA_LOG_MAX_TOTAL_MB/);
setEnv({ SNOWLUMA_LOG_MAX_TOTAL_MB: '2', SNOWLUMA_LOG_RETAIN_DAYS: '-1' });
expect(() => new FileTransport()).toThrow(/SNOWLUMA_LOG_RETAIN_DAYS/);
setEnv({ SNOWLUMA_LOG_RETAIN_DAYS: '7', SNOWLUMA_LOG_PER_UIN: 'maybe' });
expect(() => new FileTransport()).toThrow(/SNOWLUMA_LOG_PER_UIN/);
});
it('exposes initialization failures in storage status', async () => {
const invalidRoot = path.join(tmpDir, 'not-a-directory');
fs.writeFileSync(invalidRoot, 'occupied');
setEnv({ SNOWLUMA_LOG_DIR: invalidRoot });
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const t = new FileTransport();
expect(t.getStorageStatus()).toMatchObject({
state: 'disabled',
lastError: expect.stringMatching(/failed to initialize log storage/),
});
await t.close();
stderr.mockRestore();
});
it('clears active shared and account logs by rotating them into fresh empty files', async () => {
setEnv({ SNOWLUMA_LOG_PER_UIN: '1' });
const t = new FileTransport();
t.write('shared and account', 12345);
const result = await t.clearManagedLogs();
expect(result.deletedFiles).toBe(2);
expect(result.freedBytes).toBeGreaterThan(0);
expect(result.failures).toEqual([]);
expect(fs.statSync(t.currentPath!).size).toBe(0);
expect(fs.statSync(t.perUinPath(12345)!).size).toBe(0);
await t.close();
});
it('keeps files inside the retention window', async () => {
const recent = new Date();
recent.setDate(recent.getDate() - 2);
const recentName = `snowluma-${recent.getFullYear()}-${String(recent.getMonth() + 1).padStart(2, '0')}-${String(recent.getDate()).padStart(2, '0')}.log`;
const recentPath = path.join(tmpDir, recentName);
fs.writeFileSync(recentPath, 'recent\n');
const t = new FileTransport();
await t.close();
expect(fs.existsSync(recentPath)).toBe(true);
});
it('SNOWLUMA_LOG_FILE=0 suppresses all file output', async () => {
setEnv({ SNOWLUMA_LOG_FILE: '0' });
const t = new FileTransport();
t.write('should not be written');
await t.close();
expect(t.isDisabled).toBe(true);
expect(listLogFiles(tmpDir)).toHaveLength(0);
});
it('clears existing managed logs even when file output is disabled', async () => {
const managed = path.join(tmpDir, 'snowluma-2020-01-01.log');
const unrelated = path.join(tmpDir, 'keep.txt');
fs.writeFileSync(managed, 'managed\n');
fs.writeFileSync(unrelated, 'unrelated\n');
setEnv({ SNOWLUMA_LOG_FILE: '0' });
const t = new FileTransport();
const result = await t.clearManagedLogs();
expect(result).toMatchObject({
deletedFiles: 1,
failures: [],
status: { state: 'disabled', totalBytes: 0, fileCount: 0 },
});
expect(fs.existsSync(managed)).toBe(false);
expect(fs.existsSync(unrelated)).toBe(true);
await t.close();
});
it('automatically retries initialization after the log root becomes writable', async () => {
vi.useFakeTimers();
const invalidRoot = path.join(tmpDir, 'blocked-root');
fs.writeFileSync(invalidRoot, 'occupied');
setEnv({ SNOWLUMA_LOG_DIR: invalidRoot });
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const t = new FileTransport();
expect(t.getStorageStatus().state).toBe('disabled');
fs.unlinkSync(invalidRoot);
fs.mkdirSync(invalidRoot);
vi.advanceTimersByTime(5_000);
t.write('recovered');
await t.close();
expect(listLogFiles(invalidRoot)).toHaveLength(1);
expect(fs.readFileSync(path.join(invalidRoot, listLogFiles(invalidRoot)[0]!), 'utf8')).toBe(
'recovered\n',
);
stderr.mockRestore();
});
it('reopens the shared writer after a write-stream open failure', async () => {
vi.useFakeTimers();
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
vi.spyOn(fs, 'createWriteStream').mockImplementationOnce(() => {
throw Object.assign(new Error('open failed'), { code: 'EIO' });
});
const t = new FileTransport();
t.write('dropped while opening');
expect(t.getStorageStatus().state).toBe('degraded');
vi.advanceTimersByTime(5_000);
t.write('written after recovery');
await t.close();
const files = listLogFiles(tmpDir);
expect(files).toHaveLength(1);
expect(fs.readFileSync(path.join(tmpDir, files[0]!), 'utf8')).toBe(
'written after recovery\n',
);
stderr.mockRestore();
});
it('can clear managed logs before the failed writer retry delay expires', async () => {
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
vi.spyOn(fs, 'createWriteStream').mockImplementationOnce(() => {
throw Object.assign(new Error('open failed'), { code: 'EIO' });
});
const t = new FileTransport();
t.write('dropped while opening');
const stalePath = path.join(tmpDir, 'snowluma-2020-01-01.log');
fs.writeFileSync(stalePath, 'stale\n');
const result = await t.clearManagedLogs();
expect(result.failures).toEqual([]);
expect(fs.existsSync(stalePath)).toBe(false);
expect(t.currentPath).not.toBeNull();
await t.close();
stderr.mockRestore();
});
it('appends to the existing today file across restarts', async () => {
const a = new FileTransport();
a.write('before restart');
await a.close();
const b = new FileTransport();
b.write('after restart');
await b.close();
const files = listLogFiles(tmpDir);
expect(files).toHaveLength(1);
expect(fs.readFileSync(path.join(tmpDir, files[0]!), 'utf8')).toBe(
'before restart\nafter restart\n',
);
});
it('exposes the current file path while open', async () => {
const t = new FileTransport();
t.write('x');
expect(t.currentPath).not.toBeNull();
expect(t.currentPath!).toMatch(/snowluma-\d{4}-\d{2}-\d{2}\.log$/);
await t.close();
});
});