UNPKG

quantum-cli-core

Version:

Quantum CLI Core - Multi-LLM Collaboration System

387 lines 19 kB
/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach, afterAll, } from 'vitest'; import { Logger, MessageSenderType } from './logger.js'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import os from 'node:os'; const GEMINI_DIR_NAME = '.quantum'; const TMP_DIR_NAME = 'tmp'; const LOG_FILE_NAME = 'logs.json'; const CHECKPOINT_FILE_NAME = 'checkpoint.json'; const projectDir = process.cwd(); const hash = crypto.createHash('sha256').update(projectDir).digest('hex'); const TEST_GEMINI_DIR = path.join(os.homedir(), GEMINI_DIR_NAME, TMP_DIR_NAME, hash); const TEST_LOG_FILE_PATH = path.join(TEST_GEMINI_DIR, LOG_FILE_NAME); const TEST_CHECKPOINT_FILE_PATH = path.join(TEST_GEMINI_DIR, CHECKPOINT_FILE_NAME); async function cleanupLogAndCheckpointFiles() { try { await fs.rm(TEST_GEMINI_DIR, { recursive: true, force: true }); } catch (_error) { // Ignore errors, as the directory may not exist, which is fine. } } async function readLogFile() { try { const content = await fs.readFile(TEST_LOG_FILE_PATH, 'utf-8'); return JSON.parse(content); } catch (error) { if (error.code === 'ENOENT') { return []; } throw error; } } vi.mock('../utils/session.js', () => ({ sessionId: 'test-session-id', })); describe('Logger', () => { let logger; const testSessionId = 'test-session-id'; beforeEach(async () => { vi.resetAllMocks(); vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z')); // Clean up before the test await cleanupLogAndCheckpointFiles(); // Ensure the directory exists for the test await fs.mkdir(TEST_GEMINI_DIR, { recursive: true }); logger = new Logger(testSessionId); await logger.initialize(); }); afterEach(async () => { if (logger) { logger.close(); } // Clean up after the test await cleanupLogAndCheckpointFiles(); vi.useRealTimers(); vi.restoreAllMocks(); }); afterAll(async () => { // Final cleanup await cleanupLogAndCheckpointFiles(); }); describe('initialize', () => { it('should create .gemini directory and an empty log file if none exist', async () => { const dirExists = await fs .access(TEST_GEMINI_DIR) .then(() => true) .catch(() => false); expect(dirExists).toBe(true); const fileExists = await fs .access(TEST_LOG_FILE_PATH) .then(() => true) .catch(() => false); expect(fileExists).toBe(true); const logContent = await readLogFile(); expect(logContent).toEqual([]); }); it('should load existing logs and set correct messageId for the current session', async () => { const currentSessionId = 'session-123'; const anotherSessionId = 'session-456'; const existingLogs = [ { sessionId: currentSessionId, messageId: 0, timestamp: new Date('2025-01-01T10:00:05.000Z').toISOString(), type: MessageSenderType.USER, message: 'Msg1', }, { sessionId: anotherSessionId, messageId: 5, timestamp: new Date('2025-01-01T09:00:00.000Z').toISOString(), type: MessageSenderType.USER, message: 'OldMsg', }, { sessionId: currentSessionId, messageId: 1, timestamp: new Date('2025-01-01T10:00:10.000Z').toISOString(), type: MessageSenderType.USER, message: 'Msg2', }, ]; await fs.writeFile(TEST_LOG_FILE_PATH, JSON.stringify(existingLogs, null, 2)); const newLogger = new Logger(currentSessionId); await newLogger.initialize(); expect(newLogger['messageId']).toBe(2); expect(newLogger['logs']).toEqual(existingLogs); newLogger.close(); }); it('should set messageId to 0 for a new session if log file exists but has no logs for current session', async () => { const existingLogs = [ { sessionId: 'some-other-session', messageId: 5, timestamp: new Date().toISOString(), type: MessageSenderType.USER, message: 'OldMsg', }, ]; await fs.writeFile(TEST_LOG_FILE_PATH, JSON.stringify(existingLogs, null, 2)); const newLogger = new Logger('a-new-session'); await newLogger.initialize(); expect(newLogger['messageId']).toBe(0); newLogger.close(); }); it('should be idempotent', async () => { await logger.logMessage(MessageSenderType.USER, 'test message'); const initialMessageId = logger['messageId']; const initialLogCount = logger['logs'].length; await logger.initialize(); // Second call should not change state expect(logger['messageId']).toBe(initialMessageId); expect(logger['logs'].length).toBe(initialLogCount); const logsFromFile = await readLogFile(); expect(logsFromFile.length).toBe(1); }); it('should handle invalid JSON in log file by backing it up and starting fresh', async () => { await fs.writeFile(TEST_LOG_FILE_PATH, 'invalid json'); const consoleDebugSpy = vi .spyOn(console, 'debug') .mockImplementation(() => { }); const newLogger = new Logger(testSessionId); await newLogger.initialize(); expect(consoleDebugSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON in log file'), expect.any(SyntaxError)); const logContent = await readLogFile(); expect(logContent).toEqual([]); const dirContents = await fs.readdir(TEST_GEMINI_DIR); expect(dirContents.some((f) => f.startsWith(LOG_FILE_NAME + '.invalid_json') && f.endsWith('.bak'))).toBe(true); newLogger.close(); }); it('should handle non-array JSON in log file by backing it up and starting fresh', async () => { await fs.writeFile(TEST_LOG_FILE_PATH, JSON.stringify({ not: 'an array' })); const consoleDebugSpy = vi .spyOn(console, 'debug') .mockImplementation(() => { }); const newLogger = new Logger(testSessionId); await newLogger.initialize(); expect(consoleDebugSpy).toHaveBeenCalledWith(`Log file at ${TEST_LOG_FILE_PATH} is not a valid JSON array. Starting with empty logs.`); const logContent = await readLogFile(); expect(logContent).toEqual([]); const dirContents = await fs.readdir(TEST_GEMINI_DIR); expect(dirContents.some((f) => f.startsWith(LOG_FILE_NAME + '.malformed_array') && f.endsWith('.bak'))).toBe(true); newLogger.close(); }); }); describe('logMessage', () => { it('should append a message to the log file and update in-memory logs', async () => { await logger.logMessage(MessageSenderType.USER, 'Hello, world!'); const logsFromFile = await readLogFile(); expect(logsFromFile.length).toBe(1); expect(logsFromFile[0]).toMatchObject({ sessionId: testSessionId, messageId: 0, type: MessageSenderType.USER, message: 'Hello, world!', timestamp: new Date('2025-01-01T12:00:00.000Z').toISOString(), }); expect(logger['logs'].length).toBe(1); expect(logger['logs'][0]).toEqual(logsFromFile[0]); expect(logger['messageId']).toBe(1); }); it('should correctly increment messageId for subsequent messages in the same session', async () => { await logger.logMessage(MessageSenderType.USER, 'First'); vi.advanceTimersByTime(1000); await logger.logMessage(MessageSenderType.USER, 'Second'); const logs = await readLogFile(); expect(logs.length).toBe(2); expect(logs[0].messageId).toBe(0); expect(logs[1].messageId).toBe(1); expect(logs[1].timestamp).not.toBe(logs[0].timestamp); expect(logger['messageId']).toBe(2); }); it('should handle logger not initialized', async () => { const uninitializedLogger = new Logger(testSessionId); uninitializedLogger.close(); // Ensure it's treated as uninitialized const consoleDebugSpy = vi .spyOn(console, 'debug') .mockImplementation(() => { }); await uninitializedLogger.logMessage(MessageSenderType.USER, 'test'); expect(consoleDebugSpy).toHaveBeenCalledWith('Logger not initialized or session ID missing. Cannot log message.'); expect((await readLogFile()).length).toBe(0); uninitializedLogger.close(); }); it('should simulate concurrent writes from different logger instances to the same file', async () => { const concurrentSessionId = 'concurrent-session'; const logger1 = new Logger(concurrentSessionId); await logger1.initialize(); const logger2 = new Logger(concurrentSessionId); await logger2.initialize(); expect(logger2['sessionId']).toEqual(logger1['sessionId']); await logger1.logMessage(MessageSenderType.USER, 'L1M1'); vi.advanceTimersByTime(10); await logger2.logMessage(MessageSenderType.USER, 'L2M1'); vi.advanceTimersByTime(10); await logger1.logMessage(MessageSenderType.USER, 'L1M2'); vi.advanceTimersByTime(10); await logger2.logMessage(MessageSenderType.USER, 'L2M2'); const logsFromFile = await readLogFile(); expect(logsFromFile.length).toBe(4); const messageIdsInFile = logsFromFile .map((log) => log.messageId) .sort((a, b) => a - b); expect(messageIdsInFile).toEqual([0, 1, 2, 3]); const messagesInFile = logsFromFile .sort((a, b) => a.messageId - b.messageId) .map((l) => l.message); expect(messagesInFile).toEqual(['L1M1', 'L2M1', 'L1M2', 'L2M2']); // Check internal state (next messageId each logger would use for that session) expect(logger1['messageId']).toBe(3); expect(logger2['messageId']).toBe(4); logger1.close(); logger2.close(); }); it('should not throw, not increment messageId, and log error if writing to file fails', async () => { vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(new Error('Disk full')); const consoleDebugSpy = vi .spyOn(console, 'debug') .mockImplementation(() => { }); const initialMessageId = logger['messageId']; const initialLogCount = logger['logs'].length; await logger.logMessage(MessageSenderType.USER, 'test fail write'); expect(consoleDebugSpy).toHaveBeenCalledWith('Error writing to log file:', expect.any(Error)); expect(logger['messageId']).toBe(initialMessageId); // Not incremented expect(logger['logs'].length).toBe(initialLogCount); // Log not added to in-memory cache }); }); describe('getPreviousUserMessages', () => { it('should retrieve all user messages from logs, sorted newest first', async () => { const loggerSort = new Logger('session-1'); await loggerSort.initialize(); await loggerSort.logMessage(MessageSenderType.USER, 'S1M0_ts100000'); vi.advanceTimersByTime(1000); await loggerSort.logMessage(MessageSenderType.USER, 'S1M1_ts101000'); vi.advanceTimersByTime(1000); // Switch to a different session to log const loggerSort2 = new Logger('session-2'); await loggerSort2.initialize(); await loggerSort2.logMessage(MessageSenderType.USER, 'S2M0_ts102000'); vi.advanceTimersByTime(1000); await loggerSort2.logMessage('model', 'S2_Model_ts103000'); vi.advanceTimersByTime(1000); await loggerSort2.logMessage(MessageSenderType.USER, 'S2M1_ts104000'); loggerSort.close(); loggerSort2.close(); const finalLogger = new Logger('final-session'); await finalLogger.initialize(); const messages = await finalLogger.getPreviousUserMessages(); expect(messages).toEqual([ 'S2M1_ts104000', 'S2M0_ts102000', 'S1M1_ts101000', 'S1M0_ts100000', ]); finalLogger.close(); }); it('should return empty array if no user messages exist', async () => { await logger.logMessage('system', 'System boot'); const messages = await logger.getPreviousUserMessages(); expect(messages).toEqual([]); }); it('should return empty array if logger not initialized', async () => { const uninitializedLogger = new Logger(testSessionId); uninitializedLogger.close(); const messages = await uninitializedLogger.getPreviousUserMessages(); expect(messages).toEqual([]); uninitializedLogger.close(); }); }); describe('saveCheckpoint', () => { const conversation = [ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', parts: [{ text: 'Hi there' }] }, ]; it('should save a checkpoint to a tagged file when a tag is provided', async () => { const tag = 'my-test-tag'; await logger.saveCheckpoint(conversation, tag); const taggedFilePath = path.join(TEST_GEMINI_DIR, `${CHECKPOINT_FILE_NAME.replace('.json', '')}-${tag}.json`); const fileContent = await fs.readFile(taggedFilePath, 'utf-8'); expect(JSON.parse(fileContent)).toEqual(conversation); }); it('should not throw if logger is not initialized', async () => { const uninitializedLogger = new Logger(testSessionId); uninitializedLogger.close(); const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => { }); await expect(uninitializedLogger.saveCheckpoint(conversation, 'tag')).resolves.not.toThrow(); expect(consoleErrorSpy).toHaveBeenCalledWith('Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.'); }); }); describe('loadCheckpoint', () => { const conversation = [ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', parts: [{ text: 'Hi there' }] }, ]; beforeEach(async () => { await fs.writeFile(TEST_CHECKPOINT_FILE_PATH, JSON.stringify(conversation, null, 2)); }); it('should load from a tagged checkpoint file when a tag is provided', async () => { const tag = 'my-load-tag'; const taggedConversation = [ ...conversation, { role: 'user', parts: [{ text: 'Another message' }] }, ]; const taggedFilePath = path.join(TEST_GEMINI_DIR, `${CHECKPOINT_FILE_NAME.replace('.json', '')}-${tag}.json`); await fs.writeFile(taggedFilePath, JSON.stringify(taggedConversation, null, 2)); const loaded = await logger.loadCheckpoint(tag); expect(loaded).toEqual(taggedConversation); }); it('should return an empty array if a tagged checkpoint file does not exist', async () => { const loaded = await logger.loadCheckpoint('non-existent-tag'); expect(loaded).toEqual([]); }); it('should return an empty array if the checkpoint file does not exist', async () => { await fs.unlink(TEST_CHECKPOINT_FILE_PATH); // Ensure it's gone const loaded = await logger.loadCheckpoint('missing'); expect(loaded).toEqual([]); }); it('should return an empty array if the file contains invalid JSON', async () => { await fs.writeFile(TEST_CHECKPOINT_FILE_PATH, 'invalid json'); const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => { }); const loadedCheckpoint = await logger.loadCheckpoint('missing'); expect(loadedCheckpoint).toEqual([]); expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to read or parse checkpoint file'), expect.any(Error)); }); it('should return an empty array if logger is not initialized', async () => { const uninitializedLogger = new Logger(testSessionId); uninitializedLogger.close(); const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => { }); const loadedCheckpoint = await uninitializedLogger.loadCheckpoint('tag'); expect(loadedCheckpoint).toEqual([]); expect(consoleErrorSpy).toHaveBeenCalledWith('Logger not initialized or checkpoint file path not set. Cannot load checkpoint.'); }); }); describe('close', () => { it('should reset logger state', async () => { await logger.logMessage(MessageSenderType.USER, 'A message'); logger.close(); const consoleDebugSpy = vi .spyOn(console, 'debug') .mockImplementation(() => { }); await logger.logMessage(MessageSenderType.USER, 'Another message'); expect(consoleDebugSpy).toHaveBeenCalledWith('Logger not initialized or session ID missing. Cannot log message.'); const messages = await logger.getPreviousUserMessages(); expect(messages).toEqual([]); expect(logger['initialized']).toBe(false); expect(logger['logFilePath']).toBeUndefined(); expect(logger['logs']).toEqual([]); expect(logger['sessionId']).toBeUndefined(); expect(logger['messageId']).toBe(0); }); }); }); //# sourceMappingURL=logger.test.js.map