Skip to main content
Glama
post-websocket-command-executor.test.ts13.7 kB
/** * Story 5: Post-WebSocket Real-time Command Execution - Unit Tests * * Comprehensive unit tests for PostWebSocketCommandExecutor class using TDD approach. * These tests will initially FAIL and drive the implementation. * * CRITICAL: These are unit tests - they test the class logic without external dependencies. * For integration tests with real MCP and WebSocket, see separate integration test file. */ import { PostWebSocketCommandExecutor, PostWebSocketCommandExecutorConfig } from './post-websocket-command-executor'; import { MCPClient, MCPToolResponse } from './mcp-client'; import { InitialHistoryReplayCapture, CapturedMessage } from './initial-history-replay-capture'; import WebSocket from 'ws'; describe('PostWebSocketCommandExecutor - Unit Tests', () => { let executor: PostWebSocketCommandExecutor; let mockMCPClient: jest.Mocked<MCPClient>; let mockHistoryCapture: jest.Mocked<InitialHistoryReplayCapture>; let mockWebSocket: jest.Mocked<WebSocket>; beforeEach(() => { // Create mock MCPClient mockMCPClient = { callTool: jest.fn(), disconnect: jest.fn(), isConnected: jest.fn() } as unknown as jest.Mocked<MCPClient>; // Create mock InitialHistoryReplayCapture mockHistoryCapture = { captureInitialHistory: jest.fn(), waitForHistoryReplayComplete: jest.fn(), distinguishMessageTypes: jest.fn(), getHistoryMessages: jest.fn(), getRealTimeMessages: jest.fn(), isCapturing: jest.fn(), getConfig: jest.fn(), getSequenceNumber: jest.fn(), cleanup: jest.fn() } as unknown as jest.Mocked<InitialHistoryReplayCapture>; // Create mock WebSocket mockWebSocket = { on: jest.fn(), off: jest.fn(), send: jest.fn(), close: jest.fn() } as any as jest.Mocked<WebSocket>; }); afterEach(async () => { if (executor) { await executor.cleanup(); } jest.clearAllMocks(); }); describe('Constructor', () => { test('should create executor with default configuration when no config provided', () => { executor = new PostWebSocketCommandExecutor(); const config = executor.getConfig(); expect(config.commandTimeout).toBe(30000); expect(config.interCommandDelay).toBe(1000); expect(config.maxRetries).toBe(3); }); test('should create executor with custom configuration', () => { const customConfig: PostWebSocketCommandExecutorConfig = { commandTimeout: 15000, interCommandDelay: 500, maxRetries: 5 }; executor = new PostWebSocketCommandExecutor(mockMCPClient, mockHistoryCapture, customConfig); const config = executor.getConfig(); expect(config.commandTimeout).toBe(15000); expect(config.interCommandDelay).toBe(500); expect(config.maxRetries).toBe(5); }); test('should accept MCPClient and InitialHistoryReplayCapture dependencies', () => { executor = new PostWebSocketCommandExecutor(mockMCPClient, mockHistoryCapture); expect(executor).toBeDefined(); }); }); describe('executeCommands', () => { beforeEach(() => { executor = new PostWebSocketCommandExecutor(mockMCPClient, mockHistoryCapture); }); test('should throw error when execution already in progress', async () => { // Setup mock to delay first command mockMCPClient.callTool.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ success: true }), 100)) ); mockHistoryCapture.getRealTimeMessages.mockReturnValue([]); mockHistoryCapture.getHistoryMessages.mockReturnValue([]); // Start first execution (won't await) const firstExecution = executor.executeCommands(['ssh_exec whoami'], mockWebSocket); // Try to start second execution immediately await expect(executor.executeCommands(['ssh_exec ls'], mockWebSocket)) .rejects.toThrow('Command execution already in progress'); // Clean up first execution await firstExecution; }); test('should execute single command successfully', async () => { const mockResponse: MCPToolResponse = { success: true, result: { output: 'user1' } }; mockMCPClient.callTool.mockResolvedValue(mockResponse); mockHistoryCapture.getRealTimeMessages.mockReturnValue([]); mockHistoryCapture.getHistoryMessages.mockReturnValue([]); const results = await executor.executeCommands(['ssh_exec whoami'], mockWebSocket); expect(results).toHaveLength(1); expect(results[0].command).toBe('ssh_exec whoami'); expect(results[0].success).toBe(true); expect(results[0].mcpResponse).toEqual(mockResponse); expect(results[0].error).toBeUndefined(); expect(results[0].executionStartTime).toBeLessThanOrEqual(results[0].executionEndTime); }); test('should execute multiple commands sequentially with proper delay', async () => { const commands = ['ssh_exec whoami', 'ssh_exec pwd', 'ssh_exec ls']; let callOrder: number[] = []; mockMCPClient.callTool.mockImplementation(async (_toolName: string, args: Record<string, unknown>) => { callOrder.push(Date.now()); return { success: true, result: { output: `result for ${args.command}` } }; }); mockHistoryCapture.getRealTimeMessages.mockReturnValue([]); mockHistoryCapture.getHistoryMessages.mockReturnValue([]); const startTime = Date.now(); const results = await executor.executeCommands(commands, mockWebSocket); // Verify all commands executed expect(results).toHaveLength(3); expect(mockMCPClient.callTool).toHaveBeenCalledTimes(3); // Verify commands were called in correct order expect(mockMCPClient.callTool).toHaveBeenNthCalledWith(1, 'ssh_exec', { command: 'whoami' }); expect(mockMCPClient.callTool).toHaveBeenNthCalledWith(2, 'ssh_exec', { command: 'pwd' }); expect(mockMCPClient.callTool).toHaveBeenNthCalledWith(3, 'ssh_exec', { command: 'ls' }); // Verify inter-command delays were applied (should take at least 2 seconds for delays) const totalTime = Date.now() - startTime; expect(totalTime).toBeGreaterThanOrEqual(1500); // Allow some margin }); test('should handle command execution failures gracefully', async () => { const mockError = new Error('SSH connection failed'); mockMCPClient.callTool.mockRejectedValue(mockError); mockHistoryCapture.getRealTimeMessages.mockReturnValue([]); mockHistoryCapture.getHistoryMessages.mockReturnValue([]); const results = await executor.executeCommands(['ssh_exec invalid'], mockWebSocket); expect(results).toHaveLength(1); expect(results[0].command).toBe('ssh_exec invalid'); expect(results[0].success).toBe(false); expect(results[0].error).toBe('SSH connection failed'); expect(results[0].mcpResponse).toBeUndefined(); }); test('should handle MCP tool response with error', async () => { const mockResponse: MCPToolResponse = { success: false, error: 'Command execution failed' }; mockMCPClient.callTool.mockResolvedValue(mockResponse); mockHistoryCapture.getRealTimeMessages.mockReturnValue([]); mockHistoryCapture.getHistoryMessages.mockReturnValue([]); const results = await executor.executeCommands(['ssh_exec invalid'], mockWebSocket); expect(results).toHaveLength(1); expect(results[0].success).toBe(false); expect(results[0].error).toBe('Command execution failed'); expect(results[0].mcpResponse).toEqual(mockResponse); }); test('should throw error when MCPClient not provided', async () => { executor = new PostWebSocketCommandExecutor(); // No MCPClient await expect(executor.executeCommands(['ssh_exec whoami'], mockWebSocket)) .rejects.toThrow('MCPClient not provided'); }); }); describe('WebSocket Message Correlation', () => { beforeEach(() => { executor = new PostWebSocketCommandExecutor(mockMCPClient, mockHistoryCapture); }); test('should correlate WebSocket messages with command execution', async () => { const mockMessages: CapturedMessage[] = [ { timestamp: Date.now(), data: 'user1\\r\\n', type: 'websocket_received', isHistoryReplay: false, sequenceNumber: 1 }, { timestamp: Date.now() + 100, data: 'prompt> ', type: 'websocket_received', isHistoryReplay: false, sequenceNumber: 2 } ]; let messageCallCount = 0; mockHistoryCapture.getRealTimeMessages.mockImplementation(() => { messageCallCount++; if (messageCallCount === 1) return []; // Before command return mockMessages; // After command }); mockHistoryCapture.getHistoryMessages.mockReturnValue([]); mockMCPClient.callTool.mockResolvedValue({ success: true }); const results = await executor.executeCommands(['ssh_exec whoami'], mockWebSocket); expect(results[0].capturedMessages).toEqual(mockMessages); expect(mockHistoryCapture.getRealTimeMessages).toHaveBeenCalledTimes(2); }); test('should handle mixed history and real-time messages', async () => { const historyMessages: CapturedMessage[] = [ { timestamp: Date.now() - 1000, data: 'old data', type: 'history_replay', isHistoryReplay: true, sequenceNumber: 1 } ]; const realTimeMessages: CapturedMessage[] = [ { timestamp: Date.now(), data: 'new data', type: 'websocket_received', isHistoryReplay: false, sequenceNumber: 2 } ]; let messageCallCount = 0; mockHistoryCapture.getHistoryMessages.mockReturnValue(historyMessages); mockHistoryCapture.getRealTimeMessages.mockImplementation(() => { messageCallCount++; if (messageCallCount === 1) return []; // Before command return realTimeMessages; // After command }); mockMCPClient.callTool.mockResolvedValue({ success: true }); const results = await executor.executeCommands(['ssh_exec test'], mockWebSocket); // Should capture only messages after command execution expect(results[0].capturedMessages).toEqual(realTimeMessages); }); }); describe('Command Parsing', () => { beforeEach(() => { executor = new PostWebSocketCommandExecutor(mockMCPClient, mockHistoryCapture); }); test('should parse ssh_exec commands correctly', async () => { mockMCPClient.callTool.mockResolvedValue({ success: true }); mockHistoryCapture.getRealTimeMessages.mockReturnValue([]); mockHistoryCapture.getHistoryMessages.mockReturnValue([]); await executor.executeCommands(['ssh_exec whoami'], mockWebSocket); expect(mockMCPClient.callTool).toHaveBeenCalledWith('ssh_exec', { command: 'whoami' }); }); test('should parse ssh_exec commands with multiple arguments', async () => { mockMCPClient.callTool.mockResolvedValue({ success: true }); mockHistoryCapture.getRealTimeMessages.mockReturnValue([]); mockHistoryCapture.getHistoryMessages.mockReturnValue([]); await executor.executeCommands(['ssh_exec ls -la /home'], mockWebSocket); expect(mockMCPClient.callTool).toHaveBeenCalledWith('ssh_exec', { command: 'ls -la /home' }); }); test('should handle empty command gracefully', async () => { mockHistoryCapture.getRealTimeMessages.mockReturnValue([]); mockHistoryCapture.getHistoryMessages.mockReturnValue([]); const results = await executor.executeCommands([''], mockWebSocket); expect(results[0].success).toBe(false); expect(results[0].error).toBe('Empty command'); }); test('should parse non-ssh_exec commands with numbered arguments', async () => { mockMCPClient.callTool.mockResolvedValue({ success: true }); mockHistoryCapture.getRealTimeMessages.mockReturnValue([]); mockHistoryCapture.getHistoryMessages.mockReturnValue([]); await executor.executeCommands(['other_tool arg1 arg2 arg3'], mockWebSocket); expect(mockMCPClient.callTool).toHaveBeenCalledWith('other_tool', { arg1: 'arg1', arg2: 'arg2', arg3: 'arg3' }); }); }); describe('State Management', () => { test('should track execution state correctly', () => { executor = new PostWebSocketCommandExecutor(mockMCPClient, mockHistoryCapture); expect(executor.isExecuting()).toBe(false); }); test('should set executing state during command execution', async () => { executor = new PostWebSocketCommandExecutor(mockMCPClient, mockHistoryCapture); let executingDuringCommand = false; mockMCPClient.callTool.mockImplementation(async () => { executingDuringCommand = executor.isExecuting(); return { success: true }; }); mockHistoryCapture.getRealTimeMessages.mockReturnValue([]); mockHistoryCapture.getHistoryMessages.mockReturnValue([]); expect(executor.isExecuting()).toBe(false); await executor.executeCommands(['ssh_exec test'], mockWebSocket); expect(executingDuringCommand).toBe(true); expect(executor.isExecuting()).toBe(false); }); }); describe('Cleanup', () => { test('should cleanup resources properly', async () => { executor = new PostWebSocketCommandExecutor(mockMCPClient, mockHistoryCapture); await executor.cleanup(); expect(executor.isExecuting()).toBe(false); }); }); });

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/LightspeedDMS/ssh-mcp'

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