import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { appendFileSync, mkdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { tmpdir } from 'node:os'; // The logs module reads config.serversRoot lazily, so set SERVERS_ROOT // before importing it. Vitest imports the module once per file so we // stash the root globally and reset between describe blocks. const TMP_ROOT = resolve(tmpdir(), `ec-logs-test-${Date.now()}`); process.env.SERVERS_ROOT = TMP_ROOT; process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp'; process.env.BETA_ARCHIVE_DIR = resolve(TMP_ROOT, 'beta-archive'); process.env.DB_PATH = resolve(TMP_ROOT, 'test.db'); const { resolveLogPath, tailLog, readLogChunk, isLogKey } = await import('../../src/lib/server/fleet/logs.js'); const SERVER = 't5_zombie_theater_1'; const LOG_DIR = resolve(TMP_ROOT, SERVER, 'logs'); beforeEach(() => { mkdirSync(LOG_DIR, { recursive: true }); }); afterEach(() => { rmSync(TMP_ROOT, { recursive: true, force: true }); }); describe('isLogKey', () => { it('accepts whitelisted keys and rejects anything else', () => { expect(isLogKey('console')).toBe(true); expect(isLogKey('games_sp')).toBe(true); expect(isLogKey('bogus')).toBe(false); expect(isLogKey('../etc/passwd')).toBe(false); }); }); describe('resolveLogPath', () => { it('resolves whitelisted log keys under the server directory', () => { const p = resolveLogPath(SERVER, 'console'); expect(p).toBe(resolve(TMP_ROOT, SERVER, 'logs/console.log')); }); it('rejects a server name that would path-traverse', () => { // Server name with .. tries to escape the servers root const p = resolveLogPath('..', 'console'); // resolve(TMP_ROOT, '..', 'logs/console.log') escapes TMP_ROOT, // so the guard against non-serverDir-prefixed paths kicks in. // The check is against the resolved server dir; escaping should // return null. expect(p).toBeNull(); }); }); describe('tailLog', () => { it('returns null for a missing file (not an error)', async () => { const path = resolve(LOG_DIR, 'console.log'); expect(await tailLog(path, 10)).toBeNull(); }); it('returns the last N lines of a small file with truncated=false', async () => { const path = resolve(LOG_DIR, 'console.log'); const body = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n') + '\n'; writeFileSync(path, body); const tail = await tailLog(path, 5); expect(tail).not.toBeNull(); expect(tail?.lines).toEqual(['line 15', 'line 16', 'line 17', 'line 18', 'line 19']); expect(tail?.truncated).toBe(false); expect(tail?.size).toBe(body.length); }); it('caps at MAX_TAIL_LINES and marks truncated when reading past the window', async () => { const path = resolve(LOG_DIR, 'big.log'); // 500 KB of 60-char lines — comfortably larger than the 256 KB read window. const line = 'x'.repeat(59); const body = Array.from( { length: 8500 }, (_, i) => `${i.toString().padStart(4, '0')}${line.slice(4)}` ).join('\n'); writeFileSync(path, body); const tail = await tailLog(path, 100); expect(tail).not.toBeNull(); expect(tail!.truncated).toBe(true); expect(tail!.lines.length).toBeLessThanOrEqual(100); // Last line should be the highest-numbered one; first line of the tail // is somewhere in the middle since we can only read the last 256 KB. expect(tail!.lines[tail!.lines.length - 1].startsWith('8499')).toBe(true); }); it('drops the trailing empty line when file ends with a newline', async () => { const path = resolve(LOG_DIR, 'trailing.log'); writeFileSync(path, 'a\nb\nc\n'); const tail = await tailLog(path, 10); expect(tail?.lines).toEqual(['a', 'b', 'c']); }); }); describe('incremental log reads', () => { const path = resolve(LOG_DIR, 'games_sp.log'); async function read(cursor?: import('../../src/lib/server/fleet/logs.js').LogCursor) { const chunk = await readLogChunk(path, cursor); if (!chunk || !('cursor' in chunk)) throw new Error('Expected a byte chunk'); return { ...chunk, bytes: Buffer.from(chunk.contentBase64, 'base64') }; } it('reads only appended bytes and returns no body for unchanged files', async () => { expect(await readLogChunk(path)).toBeNull(); writeFileSync(path, 'first\n'); const first = await read(); expect(first.bytes.toString()).toBe('first\n'); expect(first.reset).toBe(true); const unchanged = await read(first.cursor); expect(unchanged.contentBase64).toBe(''); expect(unchanged.cursor).toEqual(first.cursor); appendFileSync(path, 'café\n'); const appended = await read(first.cursor); expect(appended.bytes.toString()).toBe('café\n'); expect(appended.reset).toBe(false); expect(appended.cursor.offset).toBe(Buffer.byteLength('first\ncafé\n')); }); it('resets after rotation, truncation and copy-truncate regrowth with a restored mtime', async () => { const header = 'same game header\n'.repeat(30); writeFileSync(path, header + 'original\n'); let chunk = await read(); renameSync(path, path + '.1'); writeFileSync(path, header + 'rotated!\n'); chunk = await read(chunk.cursor); expect(chunk.reset).toBe(true); expect(chunk.bytes.toString()).toBe(header + 'rotated!\n'); const mtime = statSync(path).mtime; writeFileSync(path, header + 'rewritten and longer\n'); utimesSync(path, mtime, mtime); chunk = await read(chunk.cursor); expect(chunk.reset).toBe(true); expect(chunk.bytes.toString()).toBe(header + 'rewritten and longer\n'); writeFileSync(path, 'short\n'); chunk = await read(chunk.cursor); expect(chunk.reset).toBe(true); expect(chunk.bytes.toString()).toBe('short\n'); }); it('bounds initial reads and drains appended backlogs even when the file stops changing', async () => { const window = 256 * 1024; writeFileSync(path, 'x'.repeat(window + 10)); const initial = await read(); expect(initial.bytes.length).toBe(window); expect(initial.truncated).toBe(true); const appended = Buffer.from('y'.repeat(window + 20)); appendFileSync(path, appended); const first = await read(initial.cursor); const second = await read(first.cursor); expect(first.reset).toBe(false); expect(first.bytes.length).toBe(window); expect(first.cursor.offset).toBeLessThan(first.size); expect(second.bytes.length).toBe(20); expect(Buffer.concat([first.bytes, second.bytes])).toEqual(appended); expect((await read(second.cursor)).contentBase64).toBe(''); }); });