/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; import { existsSync, unlinkSync } from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { type Config, debugLogger, TOOL_OUTPUTS_DIR, Storage, } from '@google/gemini-cli-core'; import type { Settings } from '../config/settings.js'; import { cleanupExpiredSessions, cleanupToolOutputFiles, } from './sessionCleanup.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, debugLogger: { error: vi.fn(), warn: vi.fn(), debug: vi.fn(), info: vi.fn(), }, }; }); describe('Session Cleanup (Refactored)', () => { let testTempDir: string; let chatsDir: string; let logsDir: string; let toolOutputsDir: string; beforeEach(async () => { vi.clearAllMocks(); testTempDir = await fs.mkdtemp( path.join(os.tmpdir(), 'gemini-cli-cleanup-test-'), ); chatsDir = path.join(testTempDir, 'chats'); logsDir = path.join(testTempDir, 'logs'); toolOutputsDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); await fs.mkdir(chatsDir, { recursive: true }); await fs.mkdir(logsDir, { recursive: true }); await fs.mkdir(toolOutputsDir, { recursive: true }); }); afterEach(async () => { vi.restoreAllMocks(); if (testTempDir && existsSync(testTempDir)) { await fs.rm(testTempDir, { recursive: true, force: true }); } }); function createMockConfig(overrides: Partial = {}): Config { return { storage: { getProjectTempDir: () => testTempDir, }, getSessionId: () => 'current123', getDebugMode: () => false, initialize: async () => {}, ...overrides, } as unknown as Config; } async function writeSessionFile(session: { id: string; fileName: string; lastUpdated: string; }) { const filePath = path.join(chatsDir, session.fileName); await fs.writeFile( filePath, JSON.stringify({ sessionId: session.id, lastUpdated: session.lastUpdated, startTime: session.lastUpdated, messages: [{ type: 'user', content: 'hello' }], }), ); } async function writeArtifacts(sessionId: string) { // Log file await fs.writeFile( path.join(logsDir, `session-${sessionId}.jsonl`), 'log content', ); // Tool output directory const sessionOutputDir = path.join(toolOutputsDir, `session-${sessionId}`); await fs.mkdir(sessionOutputDir, { recursive: true }); await fs.writeFile( path.join(sessionOutputDir, 'output.txt'), 'tool output', ); // Session directory await fs.mkdir(path.join(testTempDir, sessionId), { recursive: true }); } async function seedSessions() { const now = new Date(); const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const sessions = [ { id: 'current123', fileName: 'session-20250101-current1.json', lastUpdated: now.toISOString(), }, { id: 'old789abc', fileName: 'session-20250110-old789ab.json', lastUpdated: twoWeeksAgo.toISOString(), }, { id: 'ancient12', fileName: 'session-20241225-ancient1.json', lastUpdated: oneMonthAgo.toISOString(), }, ]; for (const session of sessions) { await writeSessionFile(session); await writeArtifacts(session.id); } return sessions; } describe('Configuration boundaries & early exits', () => { it('should return early when cleanup is disabled', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: false } }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(true); expect(result.scanned).toBe(0); expect(result.deleted).toBe(0); expect(result.skipped).toBe(0); expect(result.failed).toBe(0); }); it('should return early when sessionRetention is not configured', async () => { const config = createMockConfig(); const settings: Settings = { general: {} }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(true); expect(result.scanned).toBe(0); expect(result.deleted).toBe(0); expect(result.skipped).toBe(0); expect(result.failed).toBe(0); }); it('should require either maxAge or maxCount', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true } }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(true); expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Either maxAge or maxCount must be specified'), ); }); it.each([0, -1, -5])( 'should validate maxCount range (rejecting %i)', async (invalidCount) => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxCount: invalidCount }, }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(true); expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining('maxCount must be at least 1'), ); }, ); it('should reject if both maxAge and maxCount are invalid', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: 'invalid', maxCount: 0 }, }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(true); expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Invalid retention period format'), ); }); it('should reject if maxAge is invalid even when maxCount is valid', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: 'invalid', maxCount: 5 }, }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(true); expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Invalid retention period format'), ); }); }); describe('Logging and Debug Mode', () => { it('should log debug information when enabled', async () => { await seedSessions(); const config = createMockConfig({ getDebugMode: vi.fn().mockReturnValue(true), }); const settings: Settings = { general: { sessionRetention: { enabled: true, maxCount: 1 } }, }; const debugSpy = vi .spyOn(debugLogger, 'debug') .mockImplementation(() => {}); await cleanupExpiredSessions(config, settings); expect(debugSpy).toHaveBeenCalledWith( expect.stringContaining('Session cleanup: deleted'), ); debugSpy.mockRestore(); }); }); describe('Basic retention rules', () => { it('should delete sessions older than maxAge', async () => { const sessions = await seedSessions(); const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '10d', }, }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.scanned).toBe(3); expect(result.deleted).toBe(2); expect(result.skipped).toBe(1); expect(result.failed).toBe(0); expect(existsSync(path.join(chatsDir, sessions[0].fileName))).toBe(true); expect(existsSync(path.join(chatsDir, sessions[1].fileName))).toBe(false); expect(existsSync(path.join(chatsDir, sessions[2].fileName))).toBe(false); // Verify artifacts for an old session are gone expect( existsSync(path.join(logsDir, `session-${sessions[1].id}.jsonl`)), ).toBe(false); expect( existsSync(path.join(toolOutputsDir, `session-${sessions[1].id}`)), ).toBe(false); expect(existsSync(path.join(testTempDir, sessions[1].id))).toBe(false); // Session directory should be deleted }); it('should NOT delete sessions within the cutoff date', async () => { const sessions = await seedSessions(); // [current, 14d, 30d] const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '60d' } }, }; // 60d cutoff should keep everything that was seeded const result = await cleanupExpiredSessions(config, settings); expect(result.deleted).toBe(0); expect(result.skipped).toBe(3); for (const session of sessions) { expect(existsSync(path.join(chatsDir, session.fileName))).toBe(true); } }); it('should handle count-based retention (keeping N most recent)', async () => { const sessions = await seedSessions(); // [current, 14d, 30d] // Seed two additional granular files to prove sorting works const now = new Date(); const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); await writeSessionFile({ id: 'recent3', fileName: 'session-20250117-recent3.json', lastUpdated: threeDaysAgo.toISOString(), }); await writeArtifacts('recent3'); await writeSessionFile({ id: 'recent5', fileName: 'session-20250115-recent5.json', lastUpdated: fiveDaysAgo.toISOString(), }); await writeArtifacts('recent5'); const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxCount: 3, // Keep current + 2 most recent (which should be 3d and 5d) }, }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.scanned).toBe(5); expect(result.deleted).toBe(2); // Should only delete the 14d and 30d old sessions expect(result.skipped).toBe(3); expect(result.failed).toBe(0); // Verify specifically WHICH files survived expect(existsSync(path.join(chatsDir, sessions[0].fileName))).toBe(true); // current expect( existsSync(path.join(chatsDir, 'session-20250117-recent3.json')), ).toBe(true); // 3d expect( existsSync(path.join(chatsDir, 'session-20250115-recent5.json')), ).toBe(true); // 5d // Verify the older ones were deleted expect(existsSync(path.join(chatsDir, sessions[1].fileName))).toBe(false); // 14d expect(existsSync(path.join(chatsDir, sessions[2].fileName))).toBe(false); // 30d }); it('should delete subagent files sharing the same shortId', async () => { const now = new Date(); const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); // Parent session (expired) await writeSessionFile({ id: 'parent-uuid', fileName: 'session-20250110-abc12345.json', lastUpdated: twoWeeksAgo.toISOString(), }); await writeArtifacts('parent-uuid'); // Subagent session (different UUID, same shortId) await writeSessionFile({ id: 'sub-uuid', fileName: 'session-20250110-subagent-abc12345.json', lastUpdated: twoWeeksAgo.toISOString(), }); await writeArtifacts('sub-uuid'); const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '10d' } }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.deleted).toBe(2); // Both files should be deleted expect( existsSync(path.join(chatsDir, 'session-20250110-abc12345.json')), ).toBe(false); expect( existsSync( path.join(chatsDir, 'session-20250110-subagent-abc12345.json'), ), ).toBe(false); // Artifacts for both should be gone expect(existsSync(path.join(logsDir, 'session-parent-uuid.jsonl'))).toBe( false, ); expect(existsSync(path.join(logsDir, 'session-sub-uuid.jsonl'))).toBe( false, ); }); it('should delete corrupted session files', async () => { // Write a corrupted file (invalid JSON) const corruptPath = path.join(chatsDir, 'session-corrupt.json'); await fs.writeFile(corruptPath, 'invalid json'); const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '10d' } }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.deleted).toBe(1); expect(existsSync(corruptPath)).toBe(false); }); it('should safely delete 8-character sessions containing invalid JSON', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '1d' } }, }; const badJsonPath = path.join(chatsDir, 'session-20241225-badjson1.json'); await fs.writeFile(badJsonPath, 'This is raw text, not JSON'); const result = await cleanupExpiredSessions(config, settings); expect(result.deleted).toBe(1); expect(result.failed).toBe(0); expect(existsSync(badJsonPath)).toBe(false); }); it('should safely delete legacy non-8-character sessions', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '1d' } }, }; const legacyPath = path.join(chatsDir, 'session-20241225-legacy.json'); // Create valid JSON so the parser succeeds, but shortId derivation fails await fs.writeFile( legacyPath, JSON.stringify({ sessionId: 'legacy-session-id', lastUpdated: '2024-12-25T00:00:00.000Z', messages: [], }), ); const result = await cleanupExpiredSessions(config, settings); expect(result.deleted).toBe(1); expect(result.failed).toBe(0); expect(existsSync(legacyPath)).toBe(false); }); it('should silently ignore ENOENT if file is already deleted before unlink', async () => { await seedSessions(); // Seeds older 2024 and 2025 sessions const targetFile = path.join(chatsDir, 'session-20241225-ancient1.json'); let getSessionIdCalls = 0; const config = createMockConfig({ getSessionId: () => { getSessionIdCalls++; // First call is for `getAllSessionFiles`. // Subsequent calls are right before `fs.unlink`! if (getSessionIdCalls > 1) { try { unlinkSync(targetFile); } catch { /* ignore */ } } return 'mock-session-id'; }, }); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '1d' } }, }; const result = await cleanupExpiredSessions(config, settings); // `failed` should not increment because ENOENT is silently swallowed expect(result.failed).toBe(0); }); it('should respect minRetention configuration', async () => { await seedSessions(); const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '12h', // Less than 1 day minRetention minRetention: '1d', }, }, }; const result = await cleanupExpiredSessions(config, settings); // Should return early and not delete anything expect(result.disabled).toBe(true); expect(result.deleted).toBe(0); }); it('should handle combined maxAge and maxCount (most restrictive wins)', async () => { const sessions = await seedSessions(); // [current, 14d, 30d] // Seed 3d and 5d to mirror the granular sorting test const now = new Date(); const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); await writeSessionFile({ id: 'recent3', fileName: 'session-20250117-recent3.json', lastUpdated: threeDaysAgo.toISOString(), }); await writeArtifacts('recent3'); await writeSessionFile({ id: 'recent5', fileName: 'session-20250115-recent5.json', lastUpdated: fiveDaysAgo.toISOString(), }); await writeArtifacts('recent5'); const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, // 20d deletes 30d. // maxCount: 2 keeps current and 3d. // Restrictive wins: 30d deleted by maxAge. 14d, 5d deleted by maxCount. maxAge: '20d', maxCount: 2, }, }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.scanned).toBe(5); expect(result.deleted).toBe(3); // deletes 5d, 14d, 30d expect(result.skipped).toBe(2); // keeps current, 3d expect(result.failed).toBe(0); // Assert kept expect(existsSync(path.join(chatsDir, sessions[0].fileName))).toBe(true); // current expect( existsSync(path.join(chatsDir, 'session-20250117-recent3.json')), ).toBe(true); // 3d // Assert deleted expect( existsSync(path.join(chatsDir, 'session-20250115-recent5.json')), ).toBe(false); // 5d expect(existsSync(path.join(chatsDir, sessions[1].fileName))).toBe(false); // 14d expect(existsSync(path.join(chatsDir, sessions[2].fileName))).toBe(false); // 30d }); it('should handle empty sessions directory', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '30d' } }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(false); expect(result.scanned).toBe(0); expect(result.deleted).toBe(0); expect(result.skipped).toBe(0); expect(result.failed).toBe(0); }); }); describe('Error handling & resilience', () => { it.skipIf(process.platform === 'win32')( 'should handle file system errors gracefully (e.g., EACCES)', async () => { const sessions = await seedSessions(); const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '1d' } }, }; // Make one of the files read-only and its parent directory read-only to simulate EACCES during unlink const targetFile = path.join(chatsDir, sessions[1].fileName); await fs.chmod(targetFile, 0o444); // Wait we want unlink to fail, so we make the directory read-only temporarily await fs.chmod(chatsDir, 0o555); try { const result = await cleanupExpiredSessions(config, settings); // It shouldn't crash expect(result.disabled).toBe(false); // It should have tried and failed to delete the old session expect(result.failed).toBeGreaterThan(0); } finally { // Restore permissions so cleanup can proceed in afterEach await fs.chmod(chatsDir, 0o777); await fs.chmod(targetFile, 0o666); } }, ); it.skipIf(process.platform === 'win32')( 'should handle global read errors gracefully', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '1d' } }, }; // Make the chats directory unreadable await fs.chmod(chatsDir, 0o000); try { const result = await cleanupExpiredSessions(config, settings); // It shouldn't crash, but it should fail expect(result.disabled).toBe(false); expect(result.failed).toBe(1); expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Session cleanup failed'), ); } finally { await fs.chmod(chatsDir, 0o777); } }, ); it('should NOT delete tempDir if safeSessionId is empty', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '1d' } }, }; const sessions = await seedSessions(); const targetFile = path.join(chatsDir, sessions[1].fileName); // Write a session ID that sanitizeFilenamePart will turn into an empty string "" await fs.writeFile(targetFile, JSON.stringify({ sessionId: '../../..' })); const tempDir = config.storage.getProjectTempDir(); expect(existsSync(tempDir)).toBe(true); await cleanupExpiredSessions(config, settings); // It must NOT delete the tempDir root expect(existsSync(tempDir)).toBe(true); }); it('should handle unexpected errors without throwing (e.g. string errors)', async () => { await seedSessions(); const config = createMockConfig({ getSessionId: () => { const stringError = 'String error' as unknown as Error; throw stringError; // Throw a non-Error string without triggering no-restricted-syntax }, }); const settings: Settings = { general: { sessionRetention: { enabled: true, maxCount: 1 } }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(false); expect(result.failed).toBeGreaterThan(0); }); it('should never run on the current session', async () => { await seedSessions(); const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxCount: 1, // Keep only 1 session (which will be the current one) }, }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.deleted).toBe(2); expect(result.skipped).toBe(1); // The current session const currentSessionFile = (await fs.readdir(chatsDir)).find((f) => f.includes('current1'), ); expect(currentSessionFile).toBeDefined(); }); }); describe('Format parsing & validation', () => { // Valid formats it.each([ ['1h'], ['24h'], ['168h'], ['1d'], ['7d'], ['30d'], ['365d'], ['1w'], ['2w'], ['4w'], ['52w'], ['1m'], ['3m'], ['12m'], ['9999d'], ])('should accept valid maxAge format %s', async (input) => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: input, minRetention: '1h', }, }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(false); expect(result.failed).toBe(0); }); it('should accept maxAge equal to minRetention', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '1d', minRetention: '1d' }, }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(false); }); it('should accept maxCount = 1000 (maximum valid)', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxCount: 1000 } }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(false); }); it('should reject maxAge less than default minRetention (1d)', async () => { await seedSessions(); const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '12h', // Note: No minRetention provided here, should default to 1d }, }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(true); expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining('maxAge cannot be less than minRetention'), ); }); it('should reject maxAge less than custom minRetention', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '2d', minRetention: '3d', // maxAge < minRetention }, }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(true); expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining('maxAge cannot be less than minRetention (3d)'), ); }); it('should reject zero value with a specific error message', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '0d' } }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(true); expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Value must be greater than 0'), ); }); // Invalid formats it.each([ ['30'], ['30x'], ['d'], ['1.5d'], ['-5d'], ['1 d'], ['1dd'], ['abc'], ['30s'], ['30y'], ])('should reject invalid maxAge format %s', async (input) => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: input } }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(true); expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining(`Invalid retention period format: ${input}`), ); }); it('should reject empty string for maxAge', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '' } }, }; const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(true); expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Either maxAge or maxCount must be specified'), ); }); it('should validate minRetention format', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '5d', minRetention: 'invalid-format', }, }, }; // Should fall back to default minRetention and proceed const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(false); }); }); describe('Tool Output Cleanup', () => { let toolOutputDir: string; beforeEach(async () => { toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); await fs.mkdir(toolOutputDir, { recursive: true }); }); async function seedToolOutputs() { const now = new Date(); const oldTime = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); // 10 days ago const file1 = path.join(toolOutputDir, 'output1.json'); await fs.writeFile(file1, '{}'); const file2 = path.join(toolOutputDir, 'output2.json'); await fs.writeFile(file2, '{}'); // Manually backdate file1 await fs.utimes(file1, oldTime, oldTime); // Create an old session subdirectory const oldSubdir = path.join(toolOutputDir, 'session-old'); await fs.mkdir(oldSubdir); await fs.utimes(oldSubdir, oldTime, oldTime); return { file1, file2, oldSubdir }; } it('should return early if cleanup is disabled', async () => { const settings: Settings = { general: { sessionRetention: { enabled: false } }, }; const result = await cleanupToolOutputFiles(settings, false, testTempDir); expect(result.disabled).toBe(true); expect(result.scanned).toBe(0); expect(result.deleted).toBe(0); }); it('should gracefully handle missing tool-outputs directory', async () => { await fs.rm(toolOutputDir, { recursive: true, force: true }); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '1d' } }, }; const result = await cleanupToolOutputFiles(settings, false, testTempDir); expect(result.disabled).toBe(false); expect(result.scanned).toBe(0); }); it('should delete flat files and subdirectories based on maxAge', async () => { const { file1, file2, oldSubdir } = await seedToolOutputs(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '5d' } }, }; const result = await cleanupToolOutputFiles(settings, false, testTempDir); // file1 and oldSubdir should be deleted. expect(result.deleted).toBe(2); expect(existsSync(file1)).toBe(false); expect(existsSync(oldSubdir)).toBe(false); expect(existsSync(file2)).toBe(true); }); it('should delete oldest-first flat files based on maxCount when maxAge does not hit', async () => { const { file1, file2 } = await seedToolOutputs(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxCount: 1 } }, }; const result = await cleanupToolOutputFiles(settings, false, testTempDir); // Excess is 1. Oldest is file1. So file1 is deleted. expect(result.deleted).toBe(1); expect(existsSync(file1)).toBe(false); expect(existsSync(file2)).toBe(true); }); it('should skip tool-output subdirectories with unsafe names', async () => { const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '1d' } }, }; // Create a directory with a name that is semantically unsafe for sanitization rules const unsafeSubdir = path.join(toolOutputDir, 'session-unsafe@name'); await fs.mkdir(unsafeSubdir); // Backdate it so it WOULD be deleted if it were safely named const oldTime = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); await fs.utimes(unsafeSubdir, oldTime, oldTime); const result = await cleanupToolOutputFiles(settings, false, testTempDir); // Must be scanned but actively skipped from deletion due to sanitization mismatch expect(result.deleted).toBe(0); expect(existsSync(unsafeSubdir)).toBe(true); }); it('should initialize Storage when projectTempDir is not explicitly provided', async () => { const getProjectTempDirSpy = vi .spyOn(Storage.prototype, 'getProjectTempDir') .mockReturnValue(testTempDir); const initializeSpy = vi .spyOn(Storage.prototype, 'initialize') .mockResolvedValue(undefined); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '1d' } }, }; const { oldSubdir } = await seedToolOutputs(); // Call explicitly without third parameter const result = await cleanupToolOutputFiles(settings, false); expect(initializeSpy).toHaveBeenCalled(); expect(result.deleted).toBeGreaterThan(0); expect(existsSync(oldSubdir)).toBe(false); getProjectTempDirSpy.mockRestore(); initializeSpy.mockRestore(); }); }); });