mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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
|
// Set up stdin/stdout/stderr to use the slave PTY
|
||||||
// In nix 0.30, dup2 requires file descriptors, not raw integers
|
// 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
|
// Create OwnedFd for slave_fd
|
||||||
let slave_owned_fd = unsafe { OwnedFd::from_raw_fd(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
|
// 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();
|
let slave_fd = pty.slave.as_raw_fd();
|
||||||
|
|
||||||
// Create OwnedFd for slave and standard file descriptors
|
// Create OwnedFd for slave and standard file descriptors
|
||||||
|
|
@ -688,7 +688,7 @@ fn communication_loop(
|
||||||
}
|
}
|
||||||
|
|
||||||
if read_fds.contains(stdin.as_fd()) {
|
if read_fds.contains(stdin.as_fd()) {
|
||||||
match read(stdin.as_raw_fd(), &mut buf) {
|
match read(&stdin, &mut buf) {
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
send_eof_sequence(master.as_fd());
|
send_eof_sequence(master.as_fd());
|
||||||
read_stdin = false;
|
read_stdin = false;
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,7 @@ describe('Sessions API', () => {
|
||||||
const serverModule = await import('../../server');
|
const serverModule = await import('../../server');
|
||||||
const app = serverModule.app;
|
const app = serverModule.app;
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).get('/api/sessions').expect(200);
|
||||||
.get('/api/sessions')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(response.body).toEqual({ sessions: [] });
|
expect(response.body).toEqual({ sessions: [] });
|
||||||
});
|
});
|
||||||
|
|
@ -117,9 +115,7 @@ describe('Sessions API', () => {
|
||||||
const serverModule = await import('../../server');
|
const serverModule = await import('../../server');
|
||||||
const app = serverModule.app;
|
const app = serverModule.app;
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).get('/api/sessions').expect(200);
|
||||||
.get('/api/sessions')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(response.body.sessions).toHaveLength(1);
|
expect(response.body.sessions).toHaveLength(1);
|
||||||
expect(response.body.sessions[0]).toMatchObject({
|
expect(response.body.sessions[0]).toMatchObject({
|
||||||
|
|
@ -191,9 +187,7 @@ describe('Sessions API', () => {
|
||||||
const serverModule = await import('../../server');
|
const serverModule = await import('../../server');
|
||||||
const app = serverModule.app;
|
const app = serverModule.app;
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).delete('/api/sessions/test-session-123').expect(200);
|
||||||
.delete('/api/sessions/test-session-123')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(response.body.message).toBe('Session terminated');
|
expect(response.body.message).toBe('Session terminated');
|
||||||
|
|
||||||
|
|
@ -218,9 +212,7 @@ describe('Sessions API', () => {
|
||||||
const serverModule = await import('../../server');
|
const serverModule = await import('../../server');
|
||||||
const app = serverModule.app;
|
const app = serverModule.app;
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).delete('/api/sessions/non-existent').expect(500);
|
||||||
.delete('/api/sessions/non-existent')
|
|
||||||
.expect(500);
|
|
||||||
|
|
||||||
expect(response.body.error).toContain('Failed to terminate session');
|
expect(response.body.error).toContain('Failed to terminate session');
|
||||||
});
|
});
|
||||||
|
|
@ -231,17 +223,12 @@ describe('Sessions API', () => {
|
||||||
const serverModule = await import('../../server');
|
const serverModule = await import('../../server');
|
||||||
const app = serverModule.app;
|
const app = serverModule.app;
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).post('/api/cleanup-exited').expect(200);
|
||||||
.post('/api/cleanup-exited')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(response.body.message).toBe('Cleanup initiated');
|
expect(response.body.message).toBe('Cleanup initiated');
|
||||||
|
|
||||||
// Verify tty-fwd was called with cleanup command
|
// Verify tty-fwd was called with cleanup command
|
||||||
expect(mockSpawn).toHaveBeenCalledWith(
|
expect(mockSpawn).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining(['clean']));
|
||||||
expect.any(String),
|
|
||||||
expect.arrayContaining(['clean'])
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ vi.mock('lit', () => ({
|
||||||
connectedCallback() {}
|
connectedCallback() {}
|
||||||
disconnectedCallback() {}
|
disconnectedCallback() {}
|
||||||
requestUpdate() {}
|
requestUpdate() {}
|
||||||
dispatchEvent(event: Event) { return true; }
|
dispatchEvent(event: Event) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
html: (strings: TemplateStringsArray, ...values: any[]) => {
|
html: (strings: TemplateStringsArray, ...values: any[]) => {
|
||||||
return strings.join('');
|
return strings.join('');
|
||||||
|
|
@ -67,7 +69,7 @@ describe('SessionList Component', () => {
|
||||||
|
|
||||||
const visibleSessions = sessionList.getVisibleSessions();
|
const visibleSessions = sessionList.getVisibleSessions();
|
||||||
expect(visibleSessions).toHaveLength(2);
|
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', () => {
|
it('should show all sessions when hideExited is false', () => {
|
||||||
|
|
@ -210,15 +212,15 @@ describe('SessionList Component', () => {
|
||||||
sessionList.sessions = [
|
sessionList.sessions = [
|
||||||
createMockSession({
|
createMockSession({
|
||||||
id: '1',
|
id: '1',
|
||||||
lastModified: new Date(now.getTime() - 3600000).toISOString() // 1 hour ago
|
lastModified: new Date(now.getTime() - 3600000).toISOString(), // 1 hour ago
|
||||||
}),
|
}),
|
||||||
createMockSession({
|
createMockSession({
|
||||||
id: '2',
|
id: '2',
|
||||||
lastModified: new Date(now.getTime() - 60000).toISOString() // 1 minute ago
|
lastModified: new Date(now.getTime() - 60000).toISOString(), // 1 minute ago
|
||||||
}),
|
}),
|
||||||
createMockSession({
|
createMockSession({
|
||||||
id: '3',
|
id: '3',
|
||||||
lastModified: new Date(now.getTime() - 7200000).toISOString() // 2 hours ago
|
lastModified: new Date(now.getTime() - 7200000).toISOString(), // 2 hours ago
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@ vi.mock('lit', () => ({
|
||||||
connectedCallback() {}
|
connectedCallback() {}
|
||||||
disconnectedCallback() {}
|
disconnectedCallback() {}
|
||||||
requestUpdate() {}
|
requestUpdate() {}
|
||||||
querySelector() { return null; }
|
querySelector() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
html: (strings: TemplateStringsArray, ...values: any[]) => {
|
html: (strings: TemplateStringsArray, ...values: any[]) => {
|
||||||
return strings.join('');
|
return strings.join('');
|
||||||
|
|
|
||||||
|
|
@ -156,11 +156,7 @@ describe('Critical VibeTunnel Functionality', () => {
|
||||||
mockStreamProcess.stdout.emit('data', Buffer.from('Hello World\n'));
|
mockStreamProcess.stdout.emit('data', Buffer.from('Hello World\n'));
|
||||||
mockStreamProcess.stdout.emit('data', Buffer.from('$ '));
|
mockStreamProcess.stdout.emit('data', Buffer.from('$ '));
|
||||||
|
|
||||||
expect(streamData).toEqual([
|
expect(streamData).toEqual(['$ echo "Hello World"\n', 'Hello World\n', '$ ']);
|
||||||
'$ echo "Hello World"\n',
|
|
||||||
'Hello World\n',
|
|
||||||
'$ ',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should terminate sessions cleanly', async () => {
|
it('should terminate sessions cleanly', async () => {
|
||||||
|
|
@ -204,7 +200,7 @@ describe('Critical VibeTunnel Functionality', () => {
|
||||||
|
|
||||||
it('should handle concurrent sessions', async () => {
|
it('should handle concurrent sessions', async () => {
|
||||||
const sessions = ['session-1', 'session-2', 'session-3'];
|
const sessions = ['session-1', 'session-2', 'session-3'];
|
||||||
const processes = sessions.map(sessionId => {
|
const processes = sessions.map((sessionId) => {
|
||||||
return mockSpawn('tty-fwd', ['stream', sessionId]);
|
return mockSpawn('tty-fwd', ['stream', sessionId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -289,7 +285,7 @@ describe('Critical VibeTunnel Functionality', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(isValidSessionId(validSessionId)).toBe(true);
|
expect(isValidSessionId(validSessionId)).toBe(true);
|
||||||
invalidSessionIds.forEach(id => {
|
invalidSessionIds.forEach((id) => {
|
||||||
expect(isValidSessionId(id)).toBe(false);
|
expect(isValidSessionId(id)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -302,19 +298,13 @@ describe('Critical VibeTunnel Functionality', () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
const isSafeCommand = (cmd: string[]) => {
|
const isSafeCommand = (cmd: string[]) => {
|
||||||
const dangerousPatterns = [
|
const dangerousPatterns = [/rm\s+-rf/, /eval/, /curl.*evil/, /\$\(/, /`/];
|
||||||
/rm\s+-rf/,
|
|
||||||
/eval/,
|
|
||||||
/curl.*evil/,
|
|
||||||
/\$\(/,
|
|
||||||
/`/,
|
|
||||||
];
|
|
||||||
|
|
||||||
const cmdString = cmd.join(' ');
|
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(cmd)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -125,10 +125,10 @@ describe('Session Manager', () => {
|
||||||
|
|
||||||
const proc = mockSpawn('tty-fwd', args);
|
const proc = mockSpawn('tty-fwd', args);
|
||||||
|
|
||||||
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', expect.arrayContaining([
|
expect(mockSpawn).toHaveBeenCalledWith(
|
||||||
'--env', 'NODE_ENV=test',
|
'tty-fwd',
|
||||||
'--env', 'CUSTOM_VAR=value',
|
expect.arrayContaining(['--env', 'NODE_ENV=test', '--env', 'CUSTOM_VAR=value'])
|
||||||
]));
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should list all active sessions', async () => {
|
it('should list all active sessions', async () => {
|
||||||
|
|
@ -308,12 +308,7 @@ describe('Session Manager', () => {
|
||||||
|
|
||||||
const proc = mockSpawn('tty-fwd', ['resize', sessionId, String(cols), String(rows)]);
|
const proc = mockSpawn('tty-fwd', ['resize', sessionId, String(cols), String(rows)]);
|
||||||
|
|
||||||
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', [
|
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', ['resize', sessionId, '120', '40']);
|
||||||
'resize',
|
|
||||||
sessionId,
|
|
||||||
'120',
|
|
||||||
'40',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get terminal snapshot', async () => {
|
it('should get terminal snapshot', async () => {
|
||||||
|
|
|
||||||
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,6 +3,8 @@ import { vi } from 'vitest';
|
||||||
// Set test environment
|
// Set test environment
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
// 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
|
// Mock node-pty for tests since it requires native bindings
|
||||||
vi.mock('node-pty', () => ({
|
vi.mock('node-pty', () => ({
|
||||||
spawn: vi.fn(() => ({
|
spawn: vi.fn(() => ({
|
||||||
|
|
@ -16,6 +18,7 @@ vi.mock('node-pty', () => ({
|
||||||
onExit: vi.fn(),
|
onExit: vi.fn(),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Set up global test utilities
|
// Set up global test utilities
|
||||||
global.fetch = vi.fn();
|
global.fetch = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export const closeTestServer = async (server: Server): Promise<void> => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const waitForWebSocket = (ms: number = 100): 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 = () => {
|
export const mockWebSocketServer = () => {
|
||||||
|
|
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -162,10 +162,12 @@ describe('WebSocket Connection', () => {
|
||||||
await new Promise<void>((resolve) => ws.on('open', resolve));
|
await new Promise<void>((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
// Subscribe to terminal
|
// Subscribe to terminal
|
||||||
ws.send(JSON.stringify({
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
type: 'subscribe',
|
type: 'subscribe',
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Emit some data from the mock process
|
// Emit some data from the mock process
|
||||||
await waitForWebSocket(50);
|
await waitForWebSocket(50);
|
||||||
|
|
@ -174,7 +176,7 @@ describe('WebSocket Connection', () => {
|
||||||
await waitForWebSocket(50);
|
await waitForWebSocket(50);
|
||||||
|
|
||||||
// Check that we received the terminal output
|
// 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.length).toBeGreaterThan(0);
|
||||||
expect(terminalMessages[0]).toMatchObject({
|
expect(terminalMessages[0]).toMatchObject({
|
||||||
type: 'terminal-output',
|
type: 'terminal-output',
|
||||||
|
|
@ -191,18 +193,22 @@ describe('WebSocket Connection', () => {
|
||||||
await new Promise<void>((resolve) => ws.on('open', resolve));
|
await new Promise<void>((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
// Subscribe first
|
// Subscribe first
|
||||||
ws.send(JSON.stringify({
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
type: 'subscribe',
|
type: 'subscribe',
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await waitForWebSocket(50);
|
await waitForWebSocket(50);
|
||||||
|
|
||||||
// Then unsubscribe
|
// Then unsubscribe
|
||||||
ws.send(JSON.stringify({
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
type: 'unsubscribe',
|
type: 'unsubscribe',
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await waitForWebSocket(50);
|
await waitForWebSocket(50);
|
||||||
|
|
||||||
|
|
@ -218,12 +224,14 @@ describe('WebSocket Connection', () => {
|
||||||
await new Promise<void>((resolve) => ws.on('open', resolve));
|
await new Promise<void>((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
// Send resize event
|
// Send resize event
|
||||||
ws.send(JSON.stringify({
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
type: 'resize',
|
type: 'resize',
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
cols: 120,
|
cols: 120,
|
||||||
rows: 40,
|
rows: 40,
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await waitForWebSocket(50);
|
await waitForWebSocket(50);
|
||||||
|
|
||||||
|
|
@ -254,7 +262,7 @@ describe('WebSocket Connection', () => {
|
||||||
await waitForWebSocket(50);
|
await waitForWebSocket(50);
|
||||||
|
|
||||||
// Should receive error message
|
// 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.length).toBe(1);
|
||||||
expect(errorMessages[0].error).toContain('Invalid message format');
|
expect(errorMessages[0].error).toContain('Invalid message format');
|
||||||
|
|
||||||
|
|
@ -272,14 +280,16 @@ describe('WebSocket Connection', () => {
|
||||||
await new Promise<void>((resolve) => ws.on('open', resolve));
|
await new Promise<void>((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
// Send subscribe without sessionId
|
// Send subscribe without sessionId
|
||||||
ws.send(JSON.stringify({
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
type: 'subscribe',
|
type: 'subscribe',
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await waitForWebSocket(50);
|
await waitForWebSocket(50);
|
||||||
|
|
||||||
// Should receive error message
|
// 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.length).toBe(1);
|
||||||
expect(errorMessages[0].error).toContain('Session ID required');
|
expect(errorMessages[0].error).toContain('Session ID required');
|
||||||
|
|
||||||
|
|
@ -315,15 +325,17 @@ describe('WebSocket Connection', () => {
|
||||||
await new Promise<void>((resolve) => ws.on('open', resolve));
|
await new Promise<void>((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
// Subscribe to terminal
|
// Subscribe to terminal
|
||||||
ws.send(JSON.stringify({
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
type: 'subscribe',
|
type: 'subscribe',
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await waitForWebSocket(100);
|
await waitForWebSocket(100);
|
||||||
|
|
||||||
// Should receive error message
|
// 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.length).toBeGreaterThan(0);
|
||||||
expect(errorMessages[0].error).toContain('Failed to start stream');
|
expect(errorMessages[0].error).toContain('Failed to start stream');
|
||||||
|
|
||||||
|
|
@ -356,8 +368,8 @@ describe('WebSocket Connection', () => {
|
||||||
await waitForWebSocket(100);
|
await waitForWebSocket(100);
|
||||||
|
|
||||||
// Both clients should receive session update
|
// Both clients should receive session update
|
||||||
const updates1 = messages1.filter(m => m.type === 'sessions-updated');
|
const updates1 = messages1.filter((m) => m.type === 'sessions-updated');
|
||||||
const updates2 = messages2.filter(m => m.type === 'sessions-updated');
|
const updates2 = messages2.filter((m) => m.type === 'sessions-updated');
|
||||||
|
|
||||||
expect(updates1.length).toBeGreaterThan(0);
|
expect(updates1.length).toBeGreaterThan(0);
|
||||||
expect(updates2.length).toBeGreaterThan(0);
|
expect(updates2.length).toBeGreaterThan(0);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ export default defineConfig({
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
reporter: ['text', 'json', 'html'],
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html', 'lcov'],
|
||||||
exclude: [
|
exclude: [
|
||||||
'node_modules/',
|
'node_modules/',
|
||||||
'src/test/',
|
'src/test/',
|
||||||
|
|
@ -15,8 +16,22 @@ export default defineConfig({
|
||||||
'public/',
|
'public/',
|
||||||
'*.config.ts',
|
'*.config.ts',
|
||||||
'*.config.js',
|
'*.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: {
|
resolve: {
|
||||||
alias: {
|
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