Skip to main content
Glama
story5-terminal-state-sync-e2e.test.ts22.6 kB
/** * Story 5: Terminal State Synchronization - End-to-End Tests * * Comprehensive e2e tests that verify all acceptance criteria are met * using real WebSocket connections and terminal state management. * * NO MOCKING - Real production code execution and verification. */ import { WebServerManager } from '../src/web-server-manager.js'; import { SSHConnectionManager } from '../src/ssh-connection-manager.js'; import WebSocket from 'ws'; // Extended mock for comprehensive e2e testing class E2ETestSSHManager extends SSHConnectionManager { private testSessions = new Map<string, { commands: Array<{ command: string, source: string, timestamp: number }>; outputs: Array<{ data: string, timestamp: number }>; }>(); constructor() { super(8080); } hasSession(name: string): boolean { return this.testSessions.has(name); } createTestSession(name: string): void { this.testSessions.set(name, { commands: [], outputs: [] }); } async executeCommand(sessionName: string, command: string, options?: any): Promise<any> { if (!this.testSessions.has(sessionName)) { throw new Error(`Session ${sessionName} not found`); } const session = this.testSessions.get(sessionName)!; const source = options?.source || 'user'; // Record command execution session.commands.push({ command, source, timestamp: Date.now() }); // Simulate command execution with realistic delays await new Promise(resolve => setTimeout(resolve, 50 + Math.random() * 100)); let result: any; if (command.includes('error') || command.includes('fail')) { // Simulate command errors const error = new Error(`Command failed: ${command}`); session.outputs.push({ data: `bash: ${command}: command not found`, timestamp: Date.now() }); throw error; } else { // Simulate successful command output const output = `Output for ${source} command: ${command}`; session.outputs.push({ data: output, timestamp: Date.now() }); result = { stdout: output, stderr: '', exitCode: 0 }; } return result; } addTerminalOutputListener(): void {} removeTerminalOutputListener(): void {} getTerminalHistory(): any[] { return []; } async cleanup(): Promise<void> { this.testSessions.clear(); } // Helper methods for e2e testing getSessionCommands(sessionName: string): any[] { return this.testSessions.get(sessionName)?.commands || []; } getSessionOutputs(sessionName: string): any[] { return this.testSessions.get(sessionName)?.outputs || []; } } describe('Story 5: Terminal State Synchronization - End-to-End Tests', () => { let webServerManager: WebServerManager; let e2eSSHManager: E2ETestSSHManager; let testSessionName: string; let serverPort: number; beforeEach(async () => { e2eSSHManager = new E2ETestSSHManager(); webServerManager = new WebServerManager(e2eSSHManager); testSessionName = 'e2e-test-session'; await webServerManager.start(); serverPort = await webServerManager.getPort(); e2eSSHManager.createTestSession(testSessionName); }); afterEach(async () => { if (webServerManager) { await webServerManager.stop(); } if (e2eSSHManager) { await e2eSSHManager.cleanup(); } }); /** * AC5.1: Source-Based Terminal Unlocking * - terminal unlocks only for user-initiated command results, not Claude Code results * - remains locked until user command completes */ describe('AC5.1: Source-Based Terminal Unlocking - E2E Validation', () => { test('Complete user command lifecycle with proper locking/unlocking', async () => { const client = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); await new Promise(resolve => client.on('open', resolve)); let lockStates: any[] = []; let terminalReady = false; client.on('message', (data: any) => { const message = JSON.parse(data.toString()); if (message.type === 'terminal_lock_state') { lockStates.push(message); } if (message.type === 'terminal_ready') { terminalReady = true; } }); // Execute user command const userCommand = { type: 'terminal_input', sessionName: testSessionName, command: 'echo "user lifecycle test"', commandId: 'user-lifecycle-cmd', source: 'user' }; client.send(JSON.stringify(userCommand)); // Wait for complete command lifecycle await new Promise(resolve => setTimeout(resolve, 400)); // Verify complete lifecycle: lock -> unlock expect(lockStates.length).toBeGreaterThanOrEqual(2); expect(lockStates[0].isLocked).toBe(true); expect(lockStates[0].source).toBe('user'); const finalState = lockStates[lockStates.length - 1]; expect(finalState.isLocked).toBe(false); expect(finalState.commandId).toBeNull(); expect(terminalReady).toBe(true); // Verify command was recorded by SSH manager const commands = e2eSSHManager.getSessionCommands(testSessionName); expect(commands.length).toBe(1); expect(commands[0].command).toBe('echo "user lifecycle test"'); expect(commands[0].source).toBe('user'); client.close(); }); test('Claude Code commands do not affect terminal lock state', async () => { const client = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); await new Promise(resolve => client.on('open', resolve)); let lockStates: any[] = []; let visualIndicators: any[] = []; client.on('message', (data: any) => { const message = JSON.parse(data.toString()); if (message.type === 'terminal_lock_state') { lockStates.push(message); } if (message.type === 'visual_state_indicator') { visualIndicators.push(message); } }); // Execute Claude Code command await webServerManager.handleClaudeCodeCommand(testSessionName, 'ls -la'); await new Promise(resolve => setTimeout(resolve, 300)); // Should not receive any lock state changes for Claude commands expect(lockStates.length).toBe(0); // Should receive visual indicators for Claude commands const claudeIndicators = visualIndicators.filter(v => v.source === 'claude'); expect(claudeIndicators.length).toBeGreaterThan(0); // Verify command was recorded const commands = e2eSSHManager.getSessionCommands(testSessionName); expect(commands.length).toBe(1); expect(commands[0].source).toBe('claude'); client.close(); }); }); /** * AC5.2: Visual State Indicators * - clear visual indication during command processing * - different indicators for user vs Claude Code execution */ describe('AC5.2: Visual State Indicators - E2E Validation', () => { test('Different visual indicators for user vs Claude Code commands', async () => { const client = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); await new Promise(resolve => client.on('open', resolve)); let userIndicators: any[] = []; let claudeIndicators: any[] = []; let processingStates: any[] = []; client.on('message', (data: any) => { const message = JSON.parse(data.toString()); if (message.type === 'visual_state_indicator') { if (message.source === 'user') { userIndicators.push(message); } else if (message.source === 'claude') { claudeIndicators.push(message); } } if (message.type === 'processing_state') { processingStates.push(message); } }); // Execute user command const userCommand = { type: 'terminal_input', sessionName: testSessionName, command: 'echo "user visual test"', commandId: 'user-visual-cmd', source: 'user' }; client.send(JSON.stringify(userCommand)); await new Promise(resolve => setTimeout(resolve, 200)); // Execute Claude Code command await webServerManager.handleClaudeCodeCommand(testSessionName, 'pwd'); await new Promise(resolve => setTimeout(resolve, 200)); // Verify different indicators for different sources expect(userIndicators.length).toBeGreaterThan(0); expect(claudeIndicators.length).toBeGreaterThan(0); expect(userIndicators[0].indicatorType).toBe('user_command_executing'); expect(claudeIndicators[0].indicatorType).toBe('claude_command_executing'); // Verify processing states for user command expect(processingStates.length).toBeGreaterThanOrEqual(2); expect(processingStates[0].state).toBe('executing'); expect(processingStates[processingStates.length - 1].state).toBe('completed'); client.close(); }); }); /** * AC5.3: Multiple Client Synchronization * - all browser clients show same terminal state * - receive same output * - unlock simultaneously */ describe('AC5.3: Multiple Client Synchronization - E2E Validation', () => { test('Complete multi-client state synchronization', async () => { // Connect 3 clients simultaneously const client1 = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); const client2 = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); const client3 = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); await Promise.all([ new Promise(resolve => client1.on('open', resolve)), new Promise(resolve => client2.on('open', resolve)), new Promise(resolve => client3.on('open', resolve)) ]); let client1States: any[] = []; let client2States: any[] = []; let client3States: any[] = []; let unlockTimes: number[] = []; [client1, client2, client3].forEach((client, index) => { client.on('message', (data: any) => { const message = JSON.parse(data.toString()); if (message.type === 'terminal_lock_state') { if (index === 0) client1States.push(message); else if (index === 1) client2States.push(message); else client3States.push(message); if (!message.isLocked) { unlockTimes.push(Date.now()); } } }); }); // Send command from first client const syncCommand = { type: 'terminal_input', sessionName: testSessionName, command: 'echo "multi-client sync test"', commandId: 'multi-sync-cmd', source: 'user' }; client1.send(JSON.stringify(syncCommand)); await new Promise(resolve => setTimeout(resolve, 400)); // All clients should receive identical state updates expect(client1States.length).toBe(client2States.length); expect(client2States.length).toBe(client3States.length); expect(client1States.length).toBeGreaterThanOrEqual(2); // Verify synchronized locking expect(client1States[0].isLocked).toBe(client2States[0].isLocked); expect(client2States[0].isLocked).toBe(client3States[0].isLocked); expect(client1States[0].commandId).toBe('multi-sync-cmd'); // Verify synchronized unlocking (all clients unlock within reasonable time) expect(unlockTimes.length).toBe(3); // All three clients should unlock const maxUnlockTimeDiff = Math.max(...unlockTimes) - Math.min(...unlockTimes); expect(maxUnlockTimeDiff).toBeLessThan(100); // Within 100ms of each other client1.close(); client2.close(); client3.close(); }); }); /** * AC5.4: State Recovery After Disconnect * - browser reconnection restores correct terminal state (locked/unlocked) * - synchronizes history */ describe('AC5.4: State Recovery After Disconnect - E2E Validation', () => { test('State recovery after network disconnection', async () => { // Initial connection const client1 = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); await new Promise(resolve => client1.on('open', resolve)); // Start command but disconnect before completion const recoveryCommand = { type: 'terminal_input', sessionName: testSessionName, command: 'echo "recovery test command"', commandId: 'recovery-test-cmd', source: 'user' }; client1.send(JSON.stringify(recoveryCommand)); await new Promise(resolve => setTimeout(resolve, 100)); // Partial execution // Simulate network disconnection client1.close(); await new Promise(resolve => setTimeout(resolve, 150)); // Allow command to complete // Reconnect new client const client2 = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); let recoveryMessage: any = null; let gracefulRecovery = false; client2.on('message', (data: any) => { const message = JSON.parse(data.toString()); if (message.type === 'terminal_lock_state_recovery') { recoveryMessage = message; } if (message.type === 'graceful_recovery') { gracefulRecovery = true; } }); await new Promise(resolve => client2.on('open', resolve)); // Request state recovery client2.send(JSON.stringify({ type: 'request_state_recovery', sessionName: testSessionName })); await new Promise(resolve => setTimeout(resolve, 200)); // Should recover current state expect(recoveryMessage).toBeTruthy(); expect(gracefulRecovery).toBe(true); // Command should have completed during disconnection const commands = e2eSSHManager.getSessionCommands(testSessionName); expect(commands.length).toBe(1); expect(commands[0].command).toBe('echo "recovery test command"'); client2.close(); }); }); /** * AC5.5: Error State Handling * - terminal unlocks after user command errors * - error output with source identification * - immediate recovery */ describe('AC5.5: Error State Handling - E2E Validation', () => { test('Complete error handling lifecycle', async () => { const client = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); await new Promise(resolve => client.on('open', resolve)); let lockStates: any[] = []; let errorMessages: any[] = []; let terminalReady = false; let errorTimestamp: number = 0; let readyTimestamp: number = 0; client.on('message', (data: any) => { const message = JSON.parse(data.toString()); if (message.type === 'terminal_lock_state') { lockStates.push(message); } if (message.type === 'command_error') { errorMessages.push(message); errorTimestamp = Date.now(); } if (message.type === 'terminal_ready') { terminalReady = true; readyTimestamp = Date.now(); } }); // Send command that will fail const errorCommand = { type: 'terminal_input', sessionName: testSessionName, command: 'fail_this_command_error', commandId: 'error-test-cmd', source: 'user' }; client.send(JSON.stringify(errorCommand)); await new Promise(resolve => setTimeout(resolve, 400)); // Verify error handling sequence expect(lockStates.length).toBeGreaterThanOrEqual(2); expect(lockStates[0].isLocked).toBe(true); // Initially locked const finalLockState = lockStates[lockStates.length - 1]; expect(finalLockState.isLocked).toBe(false); // Unlocked after error // Verify error message with source identification expect(errorMessages.length).toBe(1); expect(errorMessages[0].source).toBe('user'); expect(errorMessages[0].commandId).toBe('error-test-cmd'); expect(errorMessages[0].errorMessage).toContain('Command failed'); // Verify immediate recovery (within reasonable time) expect(terminalReady).toBe(true); expect(readyTimestamp - errorTimestamp).toBeLessThan(100); // Immediate recovery client.close(); }); test('Malformed message handling without system disruption', async () => { const client = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); await new Promise(resolve => client.on('open', resolve)); let malformedHandled = false; let systemStable = true; client.on('message', (data: any) => { const message = JSON.parse(data.toString()); if (message.type === 'malformed_message_handled') { malformedHandled = true; } }); client.on('error', () => { systemStable = false; }); // Send malformed messages client.send('completely invalid json'); client.send('{"incomplete": json'); await new Promise(resolve => setTimeout(resolve, 150)); // System should handle gracefully expect(malformedHandled).toBe(true); expect(systemStable).toBe(true); expect(client.readyState).toBe(WebSocket.OPEN); // Should still accept valid commands after malformed ones const validCommand = { type: 'terminal_input', sessionName: testSessionName, command: 'echo "system recovered"', commandId: 'post-error-cmd', source: 'user' }; let commandExecuted = false; client.on('message', (data: any) => { const message = JSON.parse(data.toString()); if (message.type === 'terminal_output' && message.data.includes('system recovered')) { commandExecuted = true; } }); client.send(JSON.stringify(validCommand)); await new Promise(resolve => setTimeout(resolve, 300)); expect(commandExecuted).toBe(true); client.close(); }); }); /** * Comprehensive Integration Test - All Acceptance Criteria Together */ describe('Complete Story 5 Integration - All ACs Together', () => { test('Full terminal state synchronization workflow', async () => { // Multiple clients for comprehensive testing const userClient = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); const observerClient1 = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); const observerClient2 = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); await Promise.all([ new Promise(resolve => userClient.on('open', resolve)), new Promise(resolve => observerClient1.on('open', resolve)), new Promise(resolve => observerClient2.on('open', resolve)) ]); let allMessages: any[] = []; [userClient, observerClient1, observerClient2].forEach(client => { client.on('message', (data: any) => { const message = JSON.parse(data.toString()); allMessages.push(message); }); }); // Phase 1: User command with full synchronization const userCommand = { type: 'terminal_input', sessionName: testSessionName, command: 'echo "full integration test"', commandId: 'integration-cmd', source: 'user' }; userClient.send(JSON.stringify(userCommand)); await new Promise(resolve => setTimeout(resolve, 300)); // Phase 2: Claude Code command (should not affect locks) await webServerManager.handleClaudeCodeCommand(testSessionName, 'date'); await new Promise(resolve => setTimeout(resolve, 200)); // Phase 3: Error command const errorCommand = { type: 'terminal_input', sessionName: testSessionName, command: 'error_command_fail', commandId: 'integration-error-cmd', source: 'user' }; userClient.send(JSON.stringify(errorCommand)); await new Promise(resolve => setTimeout(resolve, 300)); // Phase 4: Recovery test userClient.close(); await new Promise(resolve => setTimeout(resolve, 100)); const recoveryClient = new WebSocket(`ws://localhost:${serverPort}/ws/session/${testSessionName}`); await new Promise(resolve => recoveryClient.on('open', resolve)); let recoveryMessages: any[] = []; recoveryClient.on('message', (data: any) => { recoveryMessages.push(JSON.parse(data.toString())); }); recoveryClient.send(JSON.stringify({ type: 'request_state_recovery', sessionName: testSessionName })); await new Promise(resolve => setTimeout(resolve, 200)); // Comprehensive Validation // AC5.1 - Source-based unlocking const lockStates = allMessages.filter(m => m.type === 'terminal_lock_state'); expect(lockStates.length).toBeGreaterThan(0); // AC5.2 - Visual indicators const userIndicators = allMessages.filter(m => m.type === 'visual_state_indicator' && m.source === 'user'); const claudeIndicators = allMessages.filter(m => m.type === 'visual_state_indicator' && m.source === 'claude'); expect(userIndicators.length).toBeGreaterThan(0); expect(claudeIndicators.length).toBeGreaterThan(0); expect(userIndicators[0].indicatorType).not.toBe(claudeIndicators[0].indicatorType); // AC5.3 - Multi-client sync (verified by message distribution) expect(allMessages.length).toBeGreaterThan(10); // Significant activity across clients // AC5.4 - Recovery const recoveryMessage = recoveryMessages.find(m => m.type === 'terminal_lock_state_recovery'); expect(recoveryMessage).toBeTruthy(); // AC5.5 - Error handling const errorMessages = allMessages.filter(m => m.type === 'command_error'); expect(errorMessages.length).toBe(1); expect(errorMessages[0].source).toBe('user'); // Overall system integrity const commands = e2eSSHManager.getSessionCommands(testSessionName); expect(commands.length).toBe(3); // user command + claude command + error command userClient.close(); observerClient1.close(); observerClient2.close(); recoveryClient.close(); }); }); });

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