import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as zlib from 'zlib'; import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types'; import { saveUnifiedConfig } from '../../../../src/config/unified-config-loader'; import { clearRecentLogEntries } from '../../../../src/services/logging/log-buffer'; import { invalidateLoggingConfigCache } from '../../../../src/services/logging/log-config'; import { getCurrentLogPath, getLogArchiveDir } from '../../../../src/services/logging/log-paths'; import { appendStructuredLogEntry, pruneExpiredLogArchives, } from '../../../../src/services/logging/log-storage'; import type { LogEntry } from '../../../../src/services/logging/log-types'; function createEntry(overrides: Partial): LogEntry { return { id: overrides.id ?? 'entry-1', timestamp: overrides.timestamp ?? new Date().toISOString(), level: overrides.level ?? 'info', source: overrides.source ?? 'unit:test', event: overrides.event ?? 'test.event', message: overrides.message ?? 'message', processId: overrides.processId ?? 1234, runId: overrides.runId ?? 'run-1', context: overrides.context ?? {}, }; } describe('log storage', () => { let tempHome = ''; let originalCcsHome: string | undefined; beforeEach(() => { originalCcsHome = process.env.CCS_HOME; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-storage-')); process.env.CCS_HOME = tempHome; clearRecentLogEntries(); invalidateLoggingConfigCache(); }); afterEach(() => { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { delete process.env.CCS_HOME; } clearRecentLogEntries(); invalidateLoggingConfigCache(); fs.rmSync(tempHome, { recursive: true, force: true }); tempHome = ''; }); it('rotates the current log into the archive when the file exceeds the age threshold', () => { const config = createEmptyUnifiedConfig(); config.logging.retain_days = 7; config.logging.rotate_mb = 10; saveUnifiedConfig(config); invalidateLoggingConfigCache(); const currentLogPath = getCurrentLogPath(); fs.mkdirSync(path.dirname(currentLogPath), { recursive: true }); fs.writeFileSync( currentLogPath, `${JSON.stringify(createEntry({ id: 'old-entry' }))}\n`, 'utf8' ); const staleTimestamp = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); fs.utimesSync(currentLogPath, staleTimestamp, staleTimestamp); appendStructuredLogEntry( createEntry({ id: 'new-entry', message: 'new log entry after rotation', }) ); const archiveDir = getLogArchiveDir(); const archives = fs.readdirSync(archiveDir); expect(archives).toHaveLength(1); const archivedContent = zlib .gunzipSync(fs.readFileSync(path.join(archiveDir, archives[0]))) .toString('utf8'); expect(archivedContent).toContain('"id":"old-entry"'); const currentContent = fs.readFileSync(currentLogPath, 'utf8'); expect(currentContent).toContain('"id":"new-entry"'); expect(currentContent).not.toContain('"id":"old-entry"'); }); it('prunes expired archives according to retention settings', () => { const config = createEmptyUnifiedConfig(); config.logging.retain_days = 1; saveUnifiedConfig(config); invalidateLoggingConfigCache(); const archiveDir = getLogArchiveDir(); fs.mkdirSync(archiveDir, { recursive: true }); const oldArchive = path.join(archiveDir, 'ccs-old.jsonl.gz'); const freshArchive = path.join(archiveDir, 'ccs-fresh.jsonl.gz'); fs.writeFileSync(oldArchive, zlib.gzipSync('old archive'), { mode: 0o600 }); fs.writeFileSync(freshArchive, zlib.gzipSync('fresh archive'), { mode: 0o600 }); const oldTimestamp = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); const freshTimestamp = new Date(Date.now() - 2 * 60 * 60 * 1000); fs.utimesSync(oldArchive, oldTimestamp, oldTimestamp); fs.utimesSync(freshArchive, freshTimestamp, freshTimestamp); pruneExpiredLogArchives(); expect(fs.existsSync(oldArchive)).toBe(false); expect(fs.existsSync(freshArchive)).toBe(true); }); });