Skip to main content
Glama
server-side-command-capture.test.ts19.1 kB
/** * TDD Tests for Server-Side Command Capture Story * * STORY REQUIREMENTS: * - Capture browser terminal commands server-side in SessionData buffer * - Maintain zero impact on user terminal experience * - Support session isolation and complex commands * * ACCEPTANCE CRITERIA: * 1. Basic Command Capture: `ls -la` captured in buffer, executes normally * 2. Multiple Command Sequence: Array of commands stored in execution order * 3. Complex Commands: Full pipeline commands captured exactly as typed * 4. Session Isolation: Commands separated per SSH session * 5. Buffer Persistence: Commands remain until explicitly cleared */ import { SSHConnectionManager } from '../src/ssh-connection-manager.js'; import { WebServerManager } from '../src/web-server-manager.js'; import { WebSocket } from 'ws'; describe('Server-Side Command Capture', () => { let sshManager: SSHConnectionManager; let webServerManager: WebServerManager; let webPort: number; const testSessionName = 'test-capture-session'; beforeAll(async () => { sshManager = new SSHConnectionManager(); webServerManager = new WebServerManager(sshManager); await webServerManager.start(); webPort = await webServerManager.getPort(); }, 30000); afterAll(async () => { await webServerManager.stop(); sshManager.cleanup(); }); beforeEach(async () => { // Create fresh SSH session for each test try { await sshManager.createConnection({ name: testSessionName, host: 'localhost', username: 'jsbattig', keyFilePath: '~/.ssh/id_ed25519' }); } catch (error) { // Session may already exist from previous test - disconnect and recreate if (sshManager.hasSession(testSessionName)) { await sshManager.disconnectSession(testSessionName); } await sshManager.createConnection({ name: testSessionName, host: 'localhost', username: 'jsbattig', keyFilePath: '~/.ssh/id_ed25519' }); } }, 20000); afterEach(async () => { if (sshManager.hasSession(testSessionName)) { await sshManager.disconnectSession(testSessionName); } }); describe('AC1: Basic Command Capture', () => { test('SHOULD capture ls -la command in browser command buffer', async () => { // ARRANGE: Set up WebSocket connection to simulate browser const wsUrl = `ws://localhost:${webPort}/ws/session/${testSessionName}`; const ws = new WebSocket(wsUrl); await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); // Wait for initial history replay await new Promise(resolve => setTimeout(resolve, 1000)); // ACT: Send terminal input command via WebSocket (simulating browser user) const testCommand = 'ls -la'; const commandId = `cmd-${Date.now()}`; ws.send(JSON.stringify({ type: 'terminal_input', sessionName: testSessionName, command: testCommand, commandId: commandId })); // Wait for command execution await new Promise(resolve => setTimeout(resolve, 2000)); // ASSERT: Command should be captured in browser command buffer // This test will FAIL initially because browserCommandBuffer doesn't exist yet const capturedCommands = sshManager.getBrowserCommandBuffer(testSessionName); expect(capturedCommands).toBeDefined(); expect(capturedCommands).toHaveLength(1); expect(capturedCommands[0]).toEqual({ command: testCommand, commandId: commandId, timestamp: expect.any(Number), source: 'user', result: { stdout: expect.any(String), // Should contain command output stderr: expect.any(String), exitCode: expect.any(Number) } }); ws.close(); }, 15000); test('SHOULD execute command normally while capturing', async () => { // ARRANGE: Set up WebSocket connection const wsUrl = `ws://localhost:${webPort}/ws/session/${testSessionName}`; const ws = new WebSocket(wsUrl); await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); let terminalOutput = ''; ws.on('message', (data) => { const message = JSON.parse(data.toString()); if (message.type === 'terminal_output') { terminalOutput += message.data; } }); // ACT: Send command via WebSocket const testCommand = 'ls -la'; const commandId = `cmd-${Date.now()}`; ws.send(JSON.stringify({ type: 'terminal_input', sessionName: testSessionName, command: testCommand, commandId: commandId })); // Wait for command execution await new Promise(resolve => setTimeout(resolve, 2000)); // ASSERT: Command should execute normally (show directory listing) expect(terminalOutput).toContain('total'); expect(terminalOutput).toContain('drwx'); // Directory permissions // AND command should be captured const capturedCommands = sshManager.getBrowserCommandBuffer(testSessionName); expect(capturedCommands).toHaveLength(1); expect(capturedCommands[0].command).toBe(testCommand); ws.close(); }, 15000); }); describe('AC2: Multiple Command Sequence', () => { test('SHOULD store array of commands in execution order', async () => { // ARRANGE: Set up WebSocket connection const wsUrl = `ws://localhost:${webPort}/ws/session/${testSessionName}`; const ws = new WebSocket(wsUrl); await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); // Wait for initial history replay await new Promise(resolve => setTimeout(resolve, 1000)); // ACT: Send multiple commands in sequence const commands = ['pwd', 'whoami', 'date']; for (let i = 0; i < commands.length; i++) { const commandId = `cmd-${Date.now()}-${i}`; ws.send(JSON.stringify({ type: 'terminal_input', sessionName: testSessionName, command: commands[i], commandId: commandId })); // Wait between commands to ensure proper sequencing await new Promise(resolve => setTimeout(resolve, 1500)); } // ASSERT: Commands should be stored in execution order // This test will FAIL initially because browserCommandBuffer doesn't exist yet const capturedCommands = sshManager.getBrowserCommandBuffer(testSessionName); expect(capturedCommands).toHaveLength(3); expect(capturedCommands[0].command).toBe('pwd'); expect(capturedCommands[1].command).toBe('whoami'); expect(capturedCommands[2].command).toBe('date'); // Verify chronological ordering by timestamp expect(capturedCommands[0].timestamp).toBeLessThan(capturedCommands[1].timestamp); expect(capturedCommands[1].timestamp).toBeLessThan(capturedCommands[2].timestamp); ws.close(); }, 25000); }); describe('AC3: Complex Commands', () => { test('SHOULD capture full pipeline commands exactly as typed', async () => { // ARRANGE: Set up WebSocket connection const wsUrl = `ws://localhost:${webPort}/ws/session/${testSessionName}`; const ws = new WebSocket(wsUrl); await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); // Wait for initial history replay await new Promise(resolve => setTimeout(resolve, 1000)); // ACT: Send complex pipeline command const complexCommand = 'ls -la | grep -E "^d" | sort -k 9'; const commandId = `cmd-${Date.now()}`; ws.send(JSON.stringify({ type: 'terminal_input', sessionName: testSessionName, command: complexCommand, commandId: commandId })); // Wait for command execution await new Promise(resolve => setTimeout(resolve, 2000)); // ASSERT: Complex command should be captured exactly as typed // This test will FAIL initially because browserCommandBuffer doesn't exist yet const capturedCommands = sshManager.getBrowserCommandBuffer(testSessionName); expect(capturedCommands).toHaveLength(1); expect(capturedCommands[0].command).toBe(complexCommand); expect(capturedCommands[0].commandId).toBe(commandId); ws.close(); }, 15000); test('SHOULD capture commands with special characters and quotes', async () => { // ARRANGE: Set up WebSocket connection const wsUrl = `ws://localhost:${webPort}/ws/session/${testSessionName}`; const ws = new WebSocket(wsUrl); await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); // Wait for initial history replay await new Promise(resolve => setTimeout(resolve, 1000)); // ACT: Send command with special characters and quotes const specialCommand = 'echo "Hello World" && echo \'Single quotes\' | cat'; const commandId = `cmd-${Date.now()}`; ws.send(JSON.stringify({ type: 'terminal_input', sessionName: testSessionName, command: specialCommand, commandId: commandId })); // Wait for command execution await new Promise(resolve => setTimeout(resolve, 2000)); // ASSERT: Special command should be captured exactly as typed // This test will FAIL initially because browserCommandBuffer doesn't exist yet const capturedCommands = sshManager.getBrowserCommandBuffer(testSessionName); expect(capturedCommands).toHaveLength(1); expect(capturedCommands[0].command).toBe(specialCommand); ws.close(); }, 15000); }); describe('AC4: Session Isolation', () => { test('SHOULD separate commands by SSH session', async () => { // ARRANGE: Create second test session const secondSessionName = 'test-capture-session-2'; await sshManager.createConnection({ name: secondSessionName, host: 'localhost', username: 'jsbattig', keyFilePath: '~/.ssh/id_ed25519' }); // Set up WebSocket connections for both sessions const ws1 = new WebSocket(`ws://localhost:${webPort}/ws/session/${testSessionName}`); const ws2 = new WebSocket(`ws://localhost:${webPort}/ws/session/${secondSessionName}`); await Promise.all([ new Promise((resolve, reject) => { ws1.on('open', resolve); ws1.on('error', reject); }), new Promise((resolve, reject) => { ws2.on('open', resolve); ws2.on('error', reject); }) ]); // Wait for initial history replay await new Promise(resolve => setTimeout(resolve, 1000)); // ACT: Send different commands to each session const command1 = 'pwd'; const command2 = 'whoami'; ws1.send(JSON.stringify({ type: 'terminal_input', sessionName: testSessionName, command: command1, commandId: `cmd1-${Date.now()}` })); ws2.send(JSON.stringify({ type: 'terminal_input', sessionName: secondSessionName, command: command2, commandId: `cmd2-${Date.now()}` })); // Wait for command execution await new Promise(resolve => setTimeout(resolve, 2000)); // ASSERT: Commands should be isolated per session // This test will FAIL initially because browserCommandBuffer doesn't exist yet const session1Commands = sshManager.getBrowserCommandBuffer(testSessionName); const session2Commands = sshManager.getBrowserCommandBuffer(secondSessionName); expect(session1Commands).toHaveLength(1); expect(session1Commands[0].command).toBe(command1); expect(session2Commands).toHaveLength(1); expect(session2Commands[0].command).toBe(command2); // Clean up second session ws1.close(); ws2.close(); await sshManager.disconnectSession(secondSessionName); }, 20000); }); describe('AC5: Buffer Persistence', () => { test('SHOULD persist commands until explicitly cleared', async () => { // ARRANGE: Set up WebSocket connection const wsUrl = `ws://localhost:${webPort}/ws/session/${testSessionName}`; const ws = new WebSocket(wsUrl); await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); // Wait for initial history replay await new Promise(resolve => setTimeout(resolve, 1000)); // ACT: Send command and close WebSocket connection const testCommand = 'ls -la'; const commandId = `cmd-${Date.now()}`; ws.send(JSON.stringify({ type: 'terminal_input', sessionName: testSessionName, command: testCommand, commandId: commandId })); // Wait for command execution await new Promise(resolve => setTimeout(resolve, 2000)); ws.close(); // Wait for connection close await new Promise(resolve => setTimeout(resolve, 500)); // ASSERT: Command should persist after WebSocket disconnection // This test will FAIL initially because browserCommandBuffer doesn't exist yet const capturedCommands = sshManager.getBrowserCommandBuffer(testSessionName); expect(capturedCommands).toBeDefined(); expect(capturedCommands).toHaveLength(1); expect(capturedCommands[0].command).toBe(testCommand); }, 15000); test('SHOULD clear buffer only when explicitly requested', async () => { // ARRANGE: Add commands to buffer first const wsUrl = `ws://localhost:${webPort}/ws/session/${testSessionName}`; const ws = new WebSocket(wsUrl); await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); // Wait for initial history replay await new Promise(resolve => setTimeout(resolve, 1000)); // Add some commands ws.send(JSON.stringify({ type: 'terminal_input', sessionName: testSessionName, command: 'pwd', commandId: `cmd-${Date.now()}` })); await new Promise(resolve => setTimeout(resolve, 1500)); ws.send(JSON.stringify({ type: 'terminal_input', sessionName: testSessionName, command: 'whoami', commandId: `cmd-${Date.now()}` })); await new Promise(resolve => setTimeout(resolve, 1500)); // Verify commands are captured let capturedCommands = sshManager.getBrowserCommandBuffer(testSessionName); expect(capturedCommands).toHaveLength(2); // ACT: Explicitly clear the buffer // This test will FAIL initially because clearBrowserCommandBuffer doesn't exist yet sshManager.clearBrowserCommandBuffer(testSessionName); // ASSERT: Buffer should be empty after explicit clear capturedCommands = sshManager.getBrowserCommandBuffer(testSessionName); expect(capturedCommands).toHaveLength(0); ws.close(); }, 20000); }); // CRITICAL ISSUE TESTS - Buffer Management describe('CRITICAL: Buffer Size Management', () => { test('SHOULD limit browserCommandBuffer to 500 entries (prevent unbounded growth)', async () => { // ARRANGE: Set up WebSocket connection const wsUrl = `ws://localhost:${webPort}/ws/session/${testSessionName}`; const ws = new WebSocket(wsUrl); await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); // Wait for initial history replay await new Promise(resolve => setTimeout(resolve, 1000)); // ACT: Add 501 commands to exceed buffer limit console.log('Adding 501 commands to test buffer size limit...'); for (let i = 0; i < 501; i++) { const commandId = `cmd-${Date.now()}-${i}`; ws.send(JSON.stringify({ type: 'terminal_input', sessionName: testSessionName, command: `echo "Command ${i}"`, commandId: commandId })); // Small delay to ensure proper sequencing await new Promise(resolve => setTimeout(resolve, 50)); } // Wait for all commands to be processed await new Promise(resolve => setTimeout(resolve, 5000)); // ASSERT: Buffer should contain only 500 entries (oldest removed) // This test will FAIL initially because buffer size limit doesn't exist yet const capturedCommands = sshManager.getBrowserCommandBuffer(testSessionName); expect(capturedCommands).toHaveLength(500); // Verify oldest command was removed (should start from command 1, not 0) expect(capturedCommands[0].command).toBe('echo "Command 1"'); expect(capturedCommands[499].command).toBe('echo "Command 500"'); ws.close(); }, 60000); }); describe('CRITICAL: Session Cleanup', () => { test('SHOULD clear browserCommandBuffer when session is disconnected', async () => { // ARRANGE: Set up WebSocket connection and add commands const wsUrl = `ws://localhost:${webPort}/ws/session/${testSessionName}`; const ws = new WebSocket(wsUrl); await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); // Wait for initial history replay await new Promise(resolve => setTimeout(resolve, 1000)); // Add some commands to the buffer for (let i = 0; i < 5; i++) { const commandId = `cmd-${Date.now()}-${i}`; ws.send(JSON.stringify({ type: 'terminal_input', sessionName: testSessionName, command: `echo "Test command ${i}"`, commandId: commandId })); await new Promise(resolve => setTimeout(resolve, 500)); } // Verify commands are in buffer let capturedCommands = sshManager.getBrowserCommandBuffer(testSessionName); expect(capturedCommands.length).toBeGreaterThan(0); console.log(`Commands in buffer before disconnect: ${capturedCommands.length}`); ws.close(); await new Promise(resolve => setTimeout(resolve, 500)); // ACT: Disconnect the SSH session (should trigger cleanup) await sshManager.disconnectSession(testSessionName); // ASSERT: browserCommandBuffer should be cleared // This test will FAIL initially because cleanup doesn't clear browserCommandBuffer try { const postDisconnectCommands = sshManager.getBrowserCommandBuffer(testSessionName); // This should either throw an error (session doesn't exist) or return empty array expect(postDisconnectCommands).toHaveLength(0); } catch (error) { // Session no longer exists is also acceptable - indicates proper cleanup expect((error as Error).message).toContain('Session'); } }, 30000); }); });

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