Skip to main content
Glama
ExecutionLogService.test.ts17.9 kB
/** * Tests for ExecutionLogService */ import { describe, test, expect, beforeEach, vi } from 'vitest'; import * as fs from 'node:fs/promises'; // Mock the fs module vi.mock('node:fs/promises'); // Import after mocking import { ExecutionLogService } from '../../src/services/ExecutionLogService'; describe('ExecutionLogService', () => { let service: ExecutionLogService; beforeEach(() => { vi.clearAllMocks(); // Create a new service instance for each test service = new ExecutionLogService('session-123'); }); describe('hasExecuted', () => { test('returns false when log file does not exist', async () => { vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' }); const result = await service.hasExecuted({ filePath: '/test/file.ts', decision: 'deny' }); expect(result).toBe(false); }); test('returns true when matching execution found', async () => { const logEntry = JSON.stringify({ timestamp: Date.now(), sessionId: 'session-123', filePath: '/test/file.ts', operation: 'edit', decision: 'deny', }); vi.mocked(fs.readFile).mockResolvedValue(logEntry); const result = await service.hasExecuted({ filePath: '/test/file.ts', decision: 'deny' }); expect(result).toBe(true); }); test.each([ ['/test/other.ts', 'deny', 'different file'], ['/test/file.ts', 'allow', 'different decision'], ])('returns false for %s', async (filePath, decision) => { const logEntry = JSON.stringify({ timestamp: Date.now(), sessionId: 'session-123', filePath: '/test/file.ts', operation: 'edit', decision: 'deny', }); vi.mocked(fs.readFile).mockResolvedValue(logEntry); const result = await service.hasExecuted({ filePath, decision }); expect(result).toBe(false); }); test('handles multiple log entries', async () => { const entries = [ { sessionId: 'session-123', filePath: '/test/file1.ts', decision: 'allow' }, { sessionId: 'session-123', filePath: '/test/file2.ts', decision: 'deny' }, { sessionId: 'session-123', filePath: '/test/file.ts', decision: 'deny' }, ].map((e) => JSON.stringify({ ...e, timestamp: Date.now(), operation: 'edit' })); vi.mocked(fs.readFile).mockResolvedValue(entries.join('\n')); const result = await service.hasExecuted({ filePath: '/test/file.ts', decision: 'deny' }); expect(result).toBe(true); }); test('handles malformed log entries gracefully', async () => { const logContent = 'invalid json\n{"valid": "entry"}'; vi.mocked(fs.readFile).mockResolvedValue(logContent); const result = await service.hasExecuted({ filePath: '/test/file.ts', decision: 'deny' }); expect(result).toBe(false); }); test('handles read errors gracefully', async () => { vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied')); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const result = await service.hasExecuted({ filePath: '/test/file.ts', decision: 'deny' }); expect(result).toBe(false); expect(consoleErrorSpy).toHaveBeenCalled(); }); }); describe('logExecution', () => { test('appends execution to log file', async () => { vi.mocked(fs.appendFile).mockResolvedValue(undefined); await service.logExecution({ filePath: '/test/file.ts', operation: 'edit', decision: 'deny', }); expect(fs.appendFile).toHaveBeenCalled(); const callArgs = vi.mocked(fs.appendFile).mock.calls[0]; const logData = JSON.parse(callArgs[1] as string); expect(logData.sessionId).toBe('session-123'); expect(logData.filePath).toBe('/test/file.ts'); expect(logData.operation).toBe('edit'); expect(logData.decision).toBe('deny'); expect(logData.timestamp).toBeDefined(); }); test('appends execution with filePattern to log file', async () => { vi.mocked(fs.appendFile).mockResolvedValue(undefined); await service.logExecution({ filePath: '/test/file.ts', operation: 'edit', decision: 'deny', filePattern: 'service-class-pattern,barrel-export-pattern', }); expect(fs.appendFile).toHaveBeenCalled(); const callArgs = vi.mocked(fs.appendFile).mock.calls[0]; const logData = JSON.parse(callArgs[1] as string); expect(logData.sessionId).toBe('session-123'); expect(logData.filePath).toBe('/test/file.ts'); expect(logData.operation).toBe('edit'); expect(logData.decision).toBe('deny'); expect(logData.filePattern).toBe('service-class-pattern,barrel-export-pattern'); expect(logData.timestamp).toBeDefined(); }); test('appends execution with generatedFiles to log file', async () => { vi.mocked(fs.appendFile).mockResolvedValue(undefined); await service.logExecution({ filePath: '/test/project', operation: 'scaffold', decision: 'allow', generatedFiles: ['/test/project/src/component.ts', '/test/project/src/component.test.ts'], }); expect(fs.appendFile).toHaveBeenCalled(); const callArgs = vi.mocked(fs.appendFile).mock.calls[0]; const logData = JSON.parse(callArgs[1] as string); expect(logData.sessionId).toBe('session-123'); expect(logData.filePath).toBe('/test/project'); expect(logData.operation).toBe('scaffold'); expect(logData.decision).toBe('allow'); expect(logData.generatedFiles).toEqual([ '/test/project/src/component.ts', '/test/project/src/component.test.ts', ]); expect(logData.timestamp).toBeDefined(); }); test('handles append errors gracefully', async () => { vi.mocked(fs.appendFile).mockRejectedValue(new Error('Disk full')); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); await service.logExecution({ filePath: '/test/file.ts', operation: 'edit', decision: 'deny', }); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Failed to log hook execution:', expect.any(Error), ); }); }); describe('getStats', () => { test('returns zero stats for empty log', async () => { vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' }); const stats = await service.getStats(); expect(stats.totalEntries).toBe(0); expect(stats.uniqueFiles).toBe(0); }); test('calculates stats correctly for multiple entries', async () => { const entries = [ { sessionId: 'session-123', filePath: '/test/file1.ts', decision: 'deny' }, { sessionId: 'session-123', filePath: '/test/file2.ts', decision: 'allow' }, { sessionId: 'session-123', filePath: '/test/file1.ts', decision: 'allow' }, ].map((e) => JSON.stringify({ ...e, timestamp: Date.now(), operation: 'edit' })); vi.mocked(fs.readFile).mockResolvedValue(entries.join('\n')); const stats = await service.getStats(); expect(stats.totalEntries).toBe(3); expect(stats.uniqueFiles).toBe(2); }); }); describe('clearLog', () => { test('removes log file successfully', async () => { vi.mocked(fs.unlink).mockResolvedValue(undefined); await service.clearLog(); expect(fs.unlink).toHaveBeenCalled(); }); test('handles missing log file gracefully', async () => { // Create a proper Node.js-style error with code property const enoentError = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT', }); vi.mocked(fs.unlink).mockRejectedValue(enoentError); await expect(service.clearLog()).resolves.toBeUndefined(); }); test('throws error for other unlink errors', async () => { vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied')); await expect(service.clearLog()).rejects.toThrow('Permission denied'); }); }); describe('wasRecentlyReviewed', () => { test('returns false when no previous review exists', async () => { vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' }); const result = await service.wasRecentlyReviewed('/test/file.ts', 3000); expect(result).toBe(false); }); test('returns true when file was reviewed within debounce window', async () => { const now = Date.now(); const recentTimestamp = now - 2000; // 2 seconds ago const logEntry = JSON.stringify({ timestamp: recentTimestamp, sessionId: 'session-123', filePath: '/test/file.ts', operation: 'edit', decision: 'allow', fileMtime: 123456789, fileChecksum: 'abc123', }); vi.mocked(fs.readFile).mockResolvedValue(logEntry); const result = await service.wasRecentlyReviewed('/test/file.ts', 3000); expect(result).toBe(true); }); test('returns false when file was reviewed outside debounce window', async () => { const now = Date.now(); const oldTimestamp = now - 5000; // 5 seconds ago const logEntry = JSON.stringify({ timestamp: oldTimestamp, sessionId: 'session-123', filePath: '/test/file.ts', operation: 'edit', decision: 'allow', }); vi.mocked(fs.readFile).mockResolvedValue(logEntry); const result = await service.wasRecentlyReviewed('/test/file.ts', 3000); expect(result).toBe(false); }); test('returns false for different file', async () => { const now = Date.now(); const recentTimestamp = now - 1000; const logEntry = JSON.stringify({ timestamp: recentTimestamp, sessionId: 'session-123', filePath: '/test/other.ts', operation: 'edit', decision: 'allow', }); vi.mocked(fs.readFile).mockResolvedValue(logEntry); const result = await service.wasRecentlyReviewed('/test/file.ts', 3000); expect(result).toBe(false); }); test('uses most recent matching entry', async () => { const now = Date.now(); const entries = [ { timestamp: now - 5000, sessionId: 'session-123', filePath: '/test/file.ts', decision: 'allow', fileMtime: 123456789, fileChecksum: 'abc123', }, { timestamp: now - 2000, sessionId: 'session-123', filePath: '/test/file.ts', decision: 'deny', fileMtime: 123456790, fileChecksum: 'def456', }, ].map((e) => JSON.stringify({ ...e, operation: 'edit' })); vi.mocked(fs.readFile).mockResolvedValue(entries.join('\n')); const result = await service.wasRecentlyReviewed('/test/file.ts', 3000); expect(result).toBe(true); }); test('uses custom debounce window', async () => { const now = Date.now(); const recentTimestamp = now - 4000; // 4 seconds ago const logEntry = JSON.stringify({ timestamp: recentTimestamp, sessionId: 'session-123', filePath: '/test/file.ts', operation: 'edit', decision: 'allow', fileMtime: 123456789, fileChecksum: 'abc123', }); // Test with 5000ms debounce - should be recent const service1 = new ExecutionLogService('session-123'); vi.mocked(fs.readFile).mockResolvedValue(logEntry); const result5s = await service1.wasRecentlyReviewed('/test/file.ts', 5000); expect(result5s).toBe(true); // Test with 3000ms debounce - should NOT be recent const service2 = new ExecutionLogService('session-123'); vi.mocked(fs.readFile).mockResolvedValue(logEntry); const result3s = await service2.wasRecentlyReviewed('/test/file.ts', 3000); expect(result3s).toBe(false); }); test('handles errors gracefully and returns false', async () => { vi.mocked(fs.readFile).mockRejectedValue(new Error('Read error')); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const result = await service.wasRecentlyReviewed('/test/file.ts', 3000); expect(result).toBe(false); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Failed to load execution log'), expect.any(Error), ); }); test('uses default debounce of 3000ms when not specified', async () => { const now = Date.now(); const recentTimestamp = now - 2000; const logEntry = JSON.stringify({ timestamp: recentTimestamp, sessionId: 'session-123', filePath: '/test/file.ts', operation: 'edit', decision: 'allow', fileMtime: 123456789, fileChecksum: 'abc123', }); vi.mocked(fs.readFile).mockResolvedValue(logEntry); const result = await service.wasRecentlyReviewed('/test/file.ts'); expect(result).toBe(true); }); test('ignores non-review operations without fileMtime or fileChecksum', async () => { const now = Date.now(); const entries = [ { timestamp: now - 1000, sessionId: 'session-123', filePath: '/test/file.ts', operation: 'read', decision: 'allow', // No fileMtime or fileChecksum - this is a non-review operation }, { timestamp: now - 5000, sessionId: 'session-123', filePath: '/test/file.ts', operation: 'edit', decision: 'allow', fileMtime: 123456789, fileChecksum: 'abc123', }, ].map((e) => JSON.stringify(e)); vi.mocked(fs.readFile).mockResolvedValue(entries.join('\n')); const result = await service.wasRecentlyReviewed('/test/file.ts', 3000); // Should return false because the only review operation (with fileMtime/fileChecksum) // is older than 3 seconds expect(result).toBe(false); }); test('ignores skip decisions even with fileMtime', async () => { const now = Date.now(); const entries = [ { timestamp: now - 1000, sessionId: 'session-123', filePath: '/test/file.ts', operation: 'edit', decision: 'skip', fileMtime: 123456789, fileChecksum: 'abc123', }, ].map((e) => JSON.stringify(e)); vi.mocked(fs.readFile).mockResolvedValue(entries.join('\n')); const result = await service.wasRecentlyReviewed('/test/file.ts', 3000); // Should return false because skip decisions are not actual reviews expect(result).toBe(false); }); }); describe('wasGeneratedByScaffold', () => { test('returns false when no scaffold log exists', async () => { vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' }); const result = await service.wasGeneratedByScaffold('/test/file.ts'); expect(result).toBe(false); }); test('returns true when file was generated by scaffold', async () => { const logEntry = JSON.stringify({ timestamp: Date.now(), sessionId: 'session-123', filePath: '/test/project', operation: 'scaffold', decision: 'allow', generatedFiles: ['/test/project/src/component.ts', '/test/project/src/component.test.ts'], }); vi.mocked(fs.readFile).mockResolvedValue(logEntry); const result = await service.wasGeneratedByScaffold('/test/project/src/component.ts'); expect(result).toBe(true); }); test('returns false when file was not in generated files list', async () => { const logEntry = JSON.stringify({ timestamp: Date.now(), sessionId: 'session-123', filePath: '/test/project', operation: 'scaffold', decision: 'allow', generatedFiles: ['/test/project/src/component.ts'], }); vi.mocked(fs.readFile).mockResolvedValue(logEntry); const result = await service.wasGeneratedByScaffold('/test/project/src/other.ts'); expect(result).toBe(false); }); test('returns false when scaffold has no generatedFiles', async () => { const logEntry = JSON.stringify({ timestamp: Date.now(), sessionId: 'session-123', filePath: '/test/project', operation: 'scaffold', decision: 'allow', }); vi.mocked(fs.readFile).mockResolvedValue(logEntry); const result = await service.wasGeneratedByScaffold('/test/project/src/component.ts'); expect(result).toBe(false); }); test('checks only scaffold operations', async () => { const entries = [ { timestamp: Date.now(), sessionId: 'session-123', filePath: '/test/file.ts', operation: 'edit', decision: 'allow', }, { timestamp: Date.now(), sessionId: 'session-123', filePath: '/test/project', operation: 'scaffold', decision: 'allow', generatedFiles: ['/test/project/src/component.ts'], }, ].map((e) => JSON.stringify(e)); vi.mocked(fs.readFile).mockResolvedValue(entries.join('\n')); const result = await service.wasGeneratedByScaffold('/test/project/src/component.ts'); expect(result).toBe(true); }); test('handles errors gracefully and returns false', async () => { vi.mocked(fs.readFile).mockRejectedValue(new Error('Read error')); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const result = await service.wasGeneratedByScaffold('/test/file.ts'); expect(result).toBe(false); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Failed to load execution log'), expect.any(Error), ); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/AgiFlow/aicode-toolkit'

If you have feedback or need assistance with the MCP directory API, please join our Discord server