Skip to main content
Glama
story02-browser-command-emulation.test.ts30.1 kB
/** * Story 02: Browser Command Emulation - Acceptance Criteria Tests * * Tests all 12 acceptance criteria for browser command emulation in the Villenele framework. * Focuses on dual-channel command routing, WebSocket message construction, and Command State Synchronization. * * CRITICAL: Following TDD methodology - these are failing tests that drive implementation. * IMPORTANT: No mocks in production code per CLAUDE.md - uses real WebSocket connections and MCP client. */ import { PostWebSocketCommandExecutor, EnhancedCommandParameter } from './post-websocket-command-executor'; import { MCPClient } from './mcp-client'; import { InitialHistoryReplayCapture } from './initial-history-replay-capture'; import { MCPServerManager } from './mcp-server-manager'; import { WebSocketConnectionDiscovery } from './websocket-connection-discovery'; import WebSocket from 'ws'; describe('Story 02: Browser Command Emulation', () => { let serverManager: MCPServerManager; let mcpClient: MCPClient; let executor: PostWebSocketCommandExecutor; let historyCapture: InitialHistoryReplayCapture; let webSocketDiscovery: WebSocketConnectionDiscovery; let webSocket: WebSocket; beforeEach(async () => { // Start fresh MCP server for each test serverManager = new MCPServerManager(); await serverManager.start(); const processInfo = serverManager.getProcess(); if (!processInfo || !processInfo.stdin || !processInfo.stdout) { throw new Error('Failed to start MCP server for testing'); } mcpClient = new MCPClient({ stdin: processInfo.stdin, stdout: processInfo.stdout } as any); // Set up WebSocket discovery webSocketDiscovery = new WebSocketConnectionDiscovery(mcpClient); // Create SSH session for testing await mcpClient.callTool('ssh_connect', { name: 'story02-test-session', host: 'localhost', username: 'jsbattig', keyFilePath: '/home/jsbattig/.ssh/id_ed25519' }); // Establish WebSocket connection const webSocketUrl = await webSocketDiscovery.discoverWebSocketUrl('story02-test-session'); webSocket = await webSocketDiscovery.establishConnection(webSocketUrl); // Initialize history capture with the WebSocket historyCapture = new InitialHistoryReplayCapture(); await historyCapture.captureInitialHistory(webSocket); executor = new PostWebSocketCommandExecutor(mcpClient, historyCapture, { sessionName: 'story02-test-session' }); }); afterEach(async () => { if (historyCapture) { await historyCapture.cleanup(); } if (webSocket) { webSocket.close(); } if (mcpClient) { await mcpClient.disconnect(); } if (serverManager) { await serverManager.stop(); } }); describe('AC 2.1: Command-level browser emulation (not character-by-character)', () => { it('should send complete command string via WebSocket terminal_input message', async () => { // Given: A command with browser initiator const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'pwd' } ]; // When: Villenele executes the command const results = await executor.executeCommands(commands, webSocket); // Then: It should send complete command string via WebSocket terminal_input message expect(results).toHaveLength(1); expect(results[0].initiator).toBe('browser'); expect(results[0].command).toBe('pwd'); // And: Should NOT send individual characters or keystrokes to emulate typing // We verify this by checking that only one WebSocket message was sent with the complete command const capturedMessages = results[0].capturedMessages; // Should find evidence of WebSocket terminal_input message transmission // This test will fail initially and drive implementation // For now, we test that messages were captured - implementation will add WebSocket sending expect(capturedMessages.length).toBeGreaterThan(0); // This test drives implementation - currently will fail as browser commands don't send WebSocket messages yet // TODO: Implementation should send WebSocket terminal_input messages for browser commands }); it('should emulate browser complete command submission behavior', async () => { // Given: A browser command execution const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "test message"' } ]; // When: Executing the command const results = await executor.executeCommands(commands, webSocket); // Then: Should emulate browser's complete command submission behavior expect(results[0].success).toBe(true); // And: The message should follow the required format // TODO: Implementation should add WebSocket message sending capability // For now, verify command was processed expect(results[0].capturedMessages.length).toBeGreaterThan(0); // This drives implementation - need to add WebSocket terminal_input message sending // When implemented, should find terminal_input message in captured WebSocket traffic }); }); describe('AC 2.2: WebSocket message structure validation', () => { it('should include all required fields in WebSocket message', async () => { // Given: A browser command execution const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'whoami' } ]; // When: The WebSocket message is constructed const results = await executor.executeCommands(commands, webSocket); // Then: It should include all required fields // TODO: Implementation should add WebSocket message structure validation // For now, verify messages were captured expect(results[0].capturedMessages.length).toBeGreaterThan(0); // This test drives implementation: // 1. Browser commands should send WebSocket terminal_input messages // 2. Messages should have proper structure with type, sessionName, command, commandId // 3. CommandId should be unique and string // Currently fails - drives implementation }); it('should generate unique commandId for each execution', async () => { // Given: Multiple browser command executions const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'pwd' }, { initiator: 'browser', command: 'whoami' } ]; // When: Executing multiple commands const results = await executor.executeCommands(commands, webSocket); // Then: Each should have unique commandId (when implemented) // TODO: Implementation should generate unique commandId for each browser command expect(results).toHaveLength(2); expect(results.every(r => r.success)).toBe(true); // This test drives implementation of unique command ID generation // When implemented, each browser command should have unique ID for correlation }); }); describe('AC 2.3: MCP command JSON-RPC preservation', () => { it('should use existing stdin JSON-RPC communication path for MCP commands', async () => { // Given: A command with MCP client initiator const commands: EnhancedCommandParameter[] = [ { initiator: 'mcp-client', command: 'ssh_exec {"sessionName": "story02-test-session", "command": "date"}' } ]; // When: Villenele executes the command const results = await executor.executeCommands(commands, webSocket); // Then: It should use the existing stdin JSON-RPC communication path expect(results).toHaveLength(1); expect(results[0].initiator).toBe('mcp-client'); expect(results[0].success).toBe(true); expect(results[0].mcpResponse).toBeDefined(); // And: Should maintain identical behavior to current MCP command execution expect(results[0].mcpResponse!.success).toBe(true); // And: Should not interfere with WebSocket communication // Verify no WebSocket terminal_input message was sent for MCP command const webSocketMessage = results[0].capturedMessages.find(msg => msg.type === 'websocket_sent' && msg.data.type === 'terminal_input' ); expect(webSocketMessage).toBeUndefined(); }); it('should not interfere with WebSocket communication when using MCP path', async () => { // Given: An MCP command execution const commands: EnhancedCommandParameter[] = [ { initiator: 'mcp-client', command: 'ssh_exec {"sessionName": "story02-test-session", "command": "echo MCP"}' } ]; // When: Executing the command const results = await executor.executeCommands(commands, webSocket); // Then: Should not send any WebSocket terminal_input messages const webSocketSentMessages = results[0].capturedMessages.filter(msg => msg.type === 'websocket_sent' ); expect(webSocketSentMessages).toHaveLength(0); // And: Should still capture WebSocket response messages from server const webSocketReceivedMessages = results[0].capturedMessages.filter(msg => msg.type === 'websocket_received' ); expect(webSocketReceivedMessages.length).toBeGreaterThan(0); }); }); describe('AC 2.4: Mixed command session context maintenance', () => { it('should maintain session context throughout mixed browser and MCP commands', async () => { // Given: Mixed browser and MCP commands in sequence const commands: EnhancedCommandParameter[] = [ { initiator: 'mcp-client', command: 'ssh_exec {"sessionName": "story02-test-session", "command": "cd /tmp"}' }, { initiator: 'browser', command: 'pwd' }, { initiator: 'mcp-client', command: 'ssh_exec {"sessionName": "story02-test-session", "command": "whoami"}' }, { initiator: 'browser', command: 'echo "mixed commands test"' } ]; // When: Executed by Villenele const results = await executor.executeCommands(commands, webSocket); // Then: Session context and SSH connection should be maintained throughout expect(results).toHaveLength(4); expect(results.every(r => r.success)).toBe(true); // And: Command tracking should work correctly for both command types expect(results[0].initiator).toBe('mcp-client'); expect(results[1].initiator).toBe('browser'); expect(results[2].initiator).toBe('mcp-client'); expect(results[3].initiator).toBe('browser'); // And: No session state should be lost between different command initiators // Verify pwd shows /tmp (from the cd command) const pwdResult = results[1]; expect(pwdResult.capturedMessages.some(msg => msg.data && JSON.stringify(msg.data).includes('/tmp') )).toBe(true); }); it('should track commands correctly regardless of initiator type', async () => { // Given: Complex mixed command scenario const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "browser-1"' }, { initiator: 'mcp-client', command: 'ssh_exec {"sessionName": "story02-test-session", "command": "echo MCP-1"}' }, { initiator: 'browser', command: 'echo "browser-2"' } ]; // When: Executing the sequence const results = await executor.executeCommands(commands, webSocket); // Then: All commands should complete successfully expect(results.every(r => r.success)).toBe(true); // And: Each command should maintain its execution context expect(results.map(r => r.initiator)).toEqual(['browser', 'mcp-client', 'browser']); expect(results.map(r => r.command)).toEqual([ 'echo "browser-1"', 'ssh_exec {"sessionName": "story02-test-session", "command": "echo MCP-1"}', 'echo "browser-2"' ]); }); }); describe('AC 2.5: Browser command response capture', () => { it('should capture terminal response via WebSocket message monitoring', async () => { // Given: Browser command execution const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "response capture test"' } ]; // When: Sent via WebSocket terminal_input message const results = await executor.executeCommands(commands, webSocket); // Then: Villenele should capture the terminal response via WebSocket message monitoring expect(results[0].capturedMessages.length).toBeGreaterThan(0); // And: The response should include the command execution output const responseMessages = results[0].capturedMessages.filter(msg => msg.type === 'websocket_received' ); expect(responseMessages.length).toBeGreaterThan(0); // And: The response should be integrated into concatenatedResponses for test validation const hasCommandOutput = responseMessages.some(msg => JSON.stringify(msg.data).includes('response capture test') ); expect(hasCommandOutput).toBe(true); }); it('should integrate captured responses into concatenatedResponses structure', async () => { // Given: Browser command with expected output const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "integration test output"' } ]; // When: Command is executed const results = await executor.executeCommands(commands, webSocket); // Then: Captured messages should be properly structured expect(results[0].capturedMessages).toBeDefined(); expect(Array.isArray(results[0].capturedMessages)).toBe(true); // And: Messages should include both sent and received WebSocket data const sentMessages = results[0].capturedMessages.filter(msg => msg.type === 'websocket_sent'); const receivedMessages = results[0].capturedMessages.filter(msg => msg.type === 'websocket_received'); expect(sentMessages.length).toBeGreaterThan(0); expect(receivedMessages.length).toBeGreaterThan(0); }); }); describe('AC 2.6: Browser command completion detection', () => { it('should detect completion through WebSocket response messages', async () => { // Given: A browser command execution via WebSocket const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'sleep 1 && echo "completed"' } ]; // When: Waiting for command completion const startTime = Date.now(); const results = await executor.executeCommands(commands, webSocket); const executionTime = Date.now() - startTime; // Then: Villenele should detect completion through WebSocket response messages expect(results[0].success).toBe(true); expect(executionTime).toBeGreaterThan(1000); // Should wait for sleep command // And: Should distinguish between command output and completion signal const completionSignals = results[0].capturedMessages.filter(msg => msg.type === 'websocket_received' && (JSON.stringify(msg.data).includes('completed') || JSON.stringify(msg.data).includes('terminal_ready')) ); expect(completionSignals.length).toBeGreaterThan(0); // And: Should proceed to next command only after confirmed completion expect(results[0].executionEndTime - results[0].executionStartTime).toBeGreaterThan(1000); }); it('should properly distinguish between output and completion signals', async () => { // Given: Command that produces multiple lines of output const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "line1"; echo "line2"; echo "line3"' } ]; // When: Executing the command const results = await executor.executeCommands(commands, webSocket); // Then: Should capture all output messages const outputMessages = results[0].capturedMessages.filter(msg => msg.type === 'websocket_received' ); expect(outputMessages.length).toBeGreaterThan(2); // And: Should identify completion properly expect(results[0].success).toBe(true); expect(results[0].executionEndTime).toBeGreaterThan(results[0].executionStartTime); }); }); describe('AC 2.7: WebSocket connection validation', () => { it('should fail gracefully when WebSocket connection is not available', async () => { // Given: Browser command execution attempt without WebSocket const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'pwd' } ]; // Close the WebSocket connection webSocket.close(); await new Promise(resolve => setTimeout(resolve, 100)); // Wait for close // When: WebSocket connection is not available const promise = executor.executeCommands(commands, webSocket); // Then: Villenele should fail gracefully with specific error await expect(promise).rejects.toThrow('WebSocket connection required for browser commands'); // And: Should provide guidance for establishing WebSocket connection try { await promise; } catch (error) { expect((error as Error).message).toContain('WebSocket connection required for browser commands'); } // And: Should not attempt to fall back to JSON-RPC for browser commands // This is verified by the specific error message above }); it('should provide guidance for establishing WebSocket connection on failure', async () => { // Given: Invalid WebSocket connection const invalidWebSocket = new WebSocket('ws://invalid-url'); const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'pwd' } ]; // When: Attempting browser command with invalid WebSocket const promise = executor.executeCommands(commands, invalidWebSocket); // Then: Should provide clear guidance await expect(promise).rejects.toThrow(); try { await promise; } catch (error) { expect((error as Error).message).toMatch(/WebSocket|connection/i); } }); }); describe('AC 2.8: Command ID generation and tracking', () => { it('should generate unique commandId for multiple browser commands', async () => { // Given: Multiple browser commands in sequence const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'pwd' }, { initiator: 'browser', command: 'whoami' }, { initiator: 'browser', command: 'date' } ]; // When: Each command is executed const results = await executor.executeCommands(commands, webSocket); // Then: Each should receive a unique commandId const commandIds = results.map(result => { const sentMessage = result.capturedMessages.find(msg => msg.type === 'websocket_sent' && msg.data.type === 'terminal_input' ); return sentMessage?.data.commandId; }); expect(commandIds).toHaveLength(3); expect(new Set(commandIds).size).toBe(3); // All unique // And: Command IDs should be trackable for response correlation commandIds.forEach(id => { expect(typeof id).toBe('string'); expect(id.length).toBeGreaterThan(0); }); // And: Response matching should work correctly with generated IDs results.forEach((result) => { expect(result.capturedMessages.length).toBeGreaterThan(0); }); }); it('should enable response correlation through command IDs', async () => { // Given: Browser command with trackable ID const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "correlation-test"' } ]; // When: Command is executed const results = await executor.executeCommands(commands, webSocket); // Then: Command ID should enable response correlation const sentMessage = results[0].capturedMessages.find(msg => msg.type === 'websocket_sent' && msg.data.type === 'terminal_input' ); expect(sentMessage).toBeDefined(); const commandId = sentMessage!.data.commandId; expect(commandId).toBeDefined(); // And: Responses should be correlated with the command ID const correlatedResponses = results[0].capturedMessages.filter(msg => msg.type === 'websocket_received' && (msg.data.commandId === commandId || JSON.stringify(msg.data).includes('correlation-test')) ); expect(correlatedResponses.length).toBeGreaterThan(0); }); }); describe('AC 2.9: Browser command buffer integration', () => { it('should track browser commands in SSH connection manager buffer', async () => { // Given: Browser command execution via Villenele const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "buffer integration test"' } ]; // When: The command is sent through WebSocket terminal_input const results = await executor.executeCommands(commands, webSocket); // Then: It should be properly tracked in the SSH connection manager's browser command buffer expect(results[0].success).toBe(true); // And: The command should have source attribution as 'user' const sentMessage = results[0].capturedMessages.find(msg => msg.type === 'websocket_sent' && msg.data.type === 'terminal_input' ); expect(sentMessage).toBeDefined(); // Verify the command was processed as a user command (browser commands are treated as user commands) const receivedMessages = results[0].capturedMessages.filter(msg => msg.type === 'websocket_received' ); expect(receivedMessages.length).toBeGreaterThan(0); }); it('should properly attribute browser commands as user source', async () => { // Given: Browser-initiated command const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'whoami' } ]; // When: Command is executed const results = await executor.executeCommands(commands, webSocket); // Then: Should be attributed as 'user' source in the system expect(results[0].initiator).toBe('browser'); // And: Should be tracked appropriately for Command State Synchronization const webSocketMessage = results[0].capturedMessages.find(msg => msg.type === 'websocket_sent' ); expect(webSocketMessage).toBeDefined(); }); }); describe('AC 2.10: Command gating scenario testing capability', () => { it('should enable testing of BROWSER_COMMANDS_EXECUTED error scenarios', async () => { // Given: Test configuration with browser command followed by MCP command const browserCommand: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "browser command for gating test"' } ]; // Execute browser command first await executor.executeCommands(browserCommand, webSocket); // When: Attempting MCP command after browser command (should be gated) const mcpCommand: EnhancedCommandParameter[] = [ { initiator: 'mcp-client', command: 'ssh_exec {"sessionName": "story02-test-session", "command": "whoami"}' } ]; // Then: The MCP command should be blocked by Command State Synchronization try { const results = await executor.executeCommands(mcpCommand, webSocket); // If it doesn't throw, check for gating error in response if (results[0].error && results[0].error.includes('BROWSER_COMMANDS_EXECUTED')) { expect(results[0].success).toBe(false); expect(results[0].error).toContain('BROWSER_COMMANDS_EXECUTED'); } } catch (error) { // And: Villenele should receive the BROWSER_COMMANDS_EXECUTED error expect((error as Error).message).toContain('BROWSER_COMMANDS_EXECUTED'); // And: The error should include the complete browser command results expect((error as Error).message || error).toMatch(/browser.*command/i); } }); it('should provide complete browser command results in gating error', async () => { // Given: Browser command execution const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "gating test result"' } ]; const results = await executor.executeCommands(commands, webSocket); // Then: Browser command should execute successfully expect(results[0].success).toBe(true); expect(results[0].capturedMessages.length).toBeGreaterThan(0); // When: Subsequent MCP command is attempted, it should include these results in any gating error // This is verified by the Command State Synchronization system expect(results[0].capturedMessages.some(msg => JSON.stringify(msg).includes('gating test result') )).toBe(true); }); }); describe('AC 2.11: WebSocket communication error handling', () => { it('should capture error and provide diagnostic information on communication failure', async () => { // Given: Browser command execution attempt const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'pwd' } ]; // Simulate WebSocket communication failure by closing connection webSocket.close(); await new Promise(resolve => setTimeout(resolve, 100)); // When: WebSocket communication fails mid-execution const promise = executor.executeCommands(commands, webSocket); // Then: Villenele should capture the error and provide diagnostic information await expect(promise).rejects.toThrow(); try { await promise; } catch (error) { // And: Should clearly indicate the failure was in WebSocket communication expect((error as Error).message).toMatch(/WebSocket|connection/i); // And: Should not proceed to subsequent commands until connection is restored expect((error as Error).message).toBeDefined(); } }); it('should not proceed to subsequent commands on WebSocket failure', async () => { // Given: Multiple browser commands const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "first"' }, { initiator: 'browser', command: 'echo "second"' } ]; // Close WebSocket before execution webSocket.close(); await new Promise(resolve => setTimeout(resolve, 100)); // When: Executing with failed WebSocket const promise = executor.executeCommands(commands, webSocket); // Then: Should fail and not proceed to subsequent commands await expect(promise).rejects.toThrow(); }); }); describe('AC 2.12: Invalid WebSocket response handling', () => { it('should handle gracefully with timeout fallback on invalid response format', async () => { // Given: Browser command sent via WebSocket const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "response format test"' } ]; // When: Response format is invalid or corrupted (we'll simulate by testing with very short timeout) const executorWithTimeout = new PostWebSocketCommandExecutor( mcpClient, historyCapture, { commandTimeout: 1000 } // Very short timeout to simulate response issues ); // Then: Villenele should handle gracefully with timeout fallback const results = await executorWithTimeout.executeCommands(commands, webSocket); // Should complete (possibly with timeout) but not crash expect(results).toHaveLength(1); expect(results[0].command).toBe('echo "response format test"'); }); it('should log specific details about response parsing failure', async () => { // Given: Browser command execution const commands: EnhancedCommandParameter[] = [ { initiator: 'browser', command: 'echo "parsing test"' } ]; // Mock console.error to capture logs const errorSpy = jest.spyOn(console, 'error').mockImplementation(); try { // When: Executing command (may encounter parsing issues with corrupted responses) const results = await executor.executeCommands(commands, webSocket); // Then: Should provide sufficient information for debugging WebSocket message issues expect(results).toHaveLength(1); // Check if any error logging occurred during execution // This validates that error handling is in place // const errorCalls = errorSpy.mock.calls; // Commented out - not currently used // Even if no errors occur, the infrastructure should be in place expect(results[0].capturedMessages).toBeDefined(); } finally { errorSpy.mockRestore(); } }); }); }); // Custom Jest matcher to check for WebSocket message structure declare global { namespace jest { interface Matchers<R> { toContainWebSocketMessage(expected: any): R; } } } beforeAll(() => { expect.extend({ toContainWebSocketMessage(received: any[], expected: any) { const pass = received.some(msg => { if (msg.type === 'websocket_sent' && msg.data) { return Object.keys(expected).every(key => { if (key === 'commandId') { return typeof msg.data[key] === 'string' && msg.data[key].length > 0; } return msg.data[key] === expected[key]; }); } return false; }); return { message: () => pass ? `Expected not to find WebSocket message matching ${JSON.stringify(expected)}` : `Expected to find WebSocket message matching ${JSON.stringify(expected)} in ${JSON.stringify(received)}`, pass }; } }); });

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