mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-27 06:20:52 -07:00
979 lines
32 KiB
TypeScript
979 lines
32 KiB
TypeScript
/**
|
|
* @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<typeof import('@google/gemini-cli-core')>();
|
|
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> = {}): 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();
|
|
});
|
|
});
|
|
});
|