feat: add integration tests and fix compatibility issues

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

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

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

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

View file

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

View file

@ -595,7 +595,7 @@ fn spawn(mut opts: SpawnOptions) -> Result<i32, Error> {
}
// Redirect stdin, stdout, stderr to the pty slave
use std::os::fd::{FromRawFd, OwnedFd, AsFd};
use std::os::fd::{FromRawFd, OwnedFd};
let slave_fd = pty.slave.as_raw_fd();
// Create OwnedFd for slave and standard file descriptors
@ -688,7 +688,7 @@ fn communication_loop(
}
if read_fds.contains(stdin.as_fd()) {
match read(stdin.as_raw_fd(), &mut buf) {
match read(&stdin, &mut buf) {
Ok(0) => {
send_eof_sequence(master.as_fd());
read_stdin = false;

View file

@ -75,9 +75,7 @@ describe('Sessions API', () => {
const serverModule = await import('../../server');
const app = serverModule.app;
const response = await request(app)
.get('/api/sessions')
.expect(200);
const response = await request(app).get('/api/sessions').expect(200);
expect(response.body).toEqual({ sessions: [] });
});
@ -117,9 +115,7 @@ describe('Sessions API', () => {
const serverModule = await import('../../server');
const app = serverModule.app;
const response = await request(app)
.get('/api/sessions')
.expect(200);
const response = await request(app).get('/api/sessions').expect(200);
expect(response.body.sessions).toHaveLength(1);
expect(response.body.sessions[0]).toMatchObject({
@ -191,9 +187,7 @@ describe('Sessions API', () => {
const serverModule = await import('../../server');
const app = serverModule.app;
const response = await request(app)
.delete('/api/sessions/test-session-123')
.expect(200);
const response = await request(app).delete('/api/sessions/test-session-123').expect(200);
expect(response.body.message).toBe('Session terminated');
@ -218,9 +212,7 @@ describe('Sessions API', () => {
const serverModule = await import('../../server');
const app = serverModule.app;
const response = await request(app)
.delete('/api/sessions/non-existent')
.expect(500);
const response = await request(app).delete('/api/sessions/non-existent').expect(500);
expect(response.body.error).toContain('Failed to terminate session');
});
@ -231,17 +223,12 @@ describe('Sessions API', () => {
const serverModule = await import('../../server');
const app = serverModule.app;
const response = await request(app)
.post('/api/cleanup-exited')
.expect(200);
const response = await request(app).post('/api/cleanup-exited').expect(200);
expect(response.body.message).toBe('Cleanup initiated');
// Verify tty-fwd was called with cleanup command
expect(mockSpawn).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining(['clean'])
);
expect(mockSpawn).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining(['clean']));
});
});

View file

@ -12,7 +12,9 @@ vi.mock('lit', () => ({
connectedCallback() {}
disconnectedCallback() {}
requestUpdate() {}
dispatchEvent(event: Event) { return true; }
dispatchEvent(event: Event) {
return true;
}
},
html: (strings: TemplateStringsArray, ...values: any[]) => {
return strings.join('');
@ -67,7 +69,7 @@ describe('SessionList Component', () => {
const visibleSessions = sessionList.getVisibleSessions();
expect(visibleSessions).toHaveLength(2);
expect(visibleSessions.every(s => s.status === 'running')).toBe(true);
expect(visibleSessions.every((s) => s.status === 'running')).toBe(true);
});
it('should show all sessions when hideExited is false', () => {
@ -210,15 +212,15 @@ describe('SessionList Component', () => {
sessionList.sessions = [
createMockSession({
id: '1',
lastModified: new Date(now.getTime() - 3600000).toISOString() // 1 hour ago
lastModified: new Date(now.getTime() - 3600000).toISOString(), // 1 hour ago
}),
createMockSession({
id: '2',
lastModified: new Date(now.getTime() - 60000).toISOString() // 1 minute ago
lastModified: new Date(now.getTime() - 60000).toISOString(), // 1 minute ago
}),
createMockSession({
id: '3',
lastModified: new Date(now.getTime() - 7200000).toISOString() // 2 hours ago
lastModified: new Date(now.getTime() - 7200000).toISOString(), // 2 hours ago
}),
];

View file

@ -43,7 +43,9 @@ vi.mock('lit', () => ({
connectedCallback() {}
disconnectedCallback() {}
requestUpdate() {}
querySelector() { return null; }
querySelector() {
return null;
}
},
html: (strings: TemplateStringsArray, ...values: any[]) => {
return strings.join('');

View file

@ -156,11 +156,7 @@ describe('Critical VibeTunnel Functionality', () => {
mockStreamProcess.stdout.emit('data', Buffer.from('Hello World\n'));
mockStreamProcess.stdout.emit('data', Buffer.from('$ '));
expect(streamData).toEqual([
'$ echo "Hello World"\n',
'Hello World\n',
'$ ',
]);
expect(streamData).toEqual(['$ echo "Hello World"\n', 'Hello World\n', '$ ']);
});
it('should terminate sessions cleanly', async () => {
@ -204,7 +200,7 @@ describe('Critical VibeTunnel Functionality', () => {
it('should handle concurrent sessions', async () => {
const sessions = ['session-1', 'session-2', 'session-3'];
const processes = sessions.map(sessionId => {
const processes = sessions.map((sessionId) => {
return mockSpawn('tty-fwd', ['stream', sessionId]);
});
@ -289,7 +285,7 @@ describe('Critical VibeTunnel Functionality', () => {
};
expect(isValidSessionId(validSessionId)).toBe(true);
invalidSessionIds.forEach(id => {
invalidSessionIds.forEach((id) => {
expect(isValidSessionId(id)).toBe(false);
});
});
@ -302,19 +298,13 @@ describe('Critical VibeTunnel Functionality', () => {
];
const isSafeCommand = (cmd: string[]) => {
const dangerousPatterns = [
/rm\s+-rf/,
/eval/,
/curl.*evil/,
/\$\(/,
/`/,
];
const dangerousPatterns = [/rm\s+-rf/, /eval/, /curl.*evil/, /\$\(/, /`/];
const cmdString = cmd.join(' ');
return !dangerousPatterns.some(pattern => pattern.test(cmdString));
return !dangerousPatterns.some((pattern) => pattern.test(cmdString));
};
dangerousCommands.forEach(cmd => {
dangerousCommands.forEach((cmd) => {
expect(isSafeCommand(cmd)).toBe(false);
});

View file

@ -0,0 +1,423 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { Server } from 'http';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
// @ts-expect-error - TypeScript module imports in tests
import { app, server } from '../../server';
// Set up test environment
process.env.NODE_ENV = 'test';
process.env.PORT = '0'; // Random port
const testControlDir = path.join(os.tmpdir(), 'vibetunnel-test', uuidv4());
process.env.TTY_FWD_CONTROL_DIR = testControlDir;
// Ensure test directory exists
beforeAll(() => {
if (!fs.existsSync(testControlDir)) {
fs.mkdirSync(testControlDir, { recursive: true });
}
});
afterAll(() => {
// Clean up test directory
if (fs.existsSync(testControlDir)) {
fs.rmSync(testControlDir, { recursive: true, force: true });
}
});
describe('API Integration Tests', () => {
let port: number;
let baseUrl: string;
let activeSessionIds: string[] = [];
beforeAll(async () => {
// Get the port the server is listening on
await new Promise<void>((resolve) => {
if (!server.listening) {
server.listen(0, () => {
const address = server.address();
port = (address as any).port;
baseUrl = `http://localhost:${port}`;
resolve();
});
} else {
const address = server.address();
port = (address as any).port;
baseUrl = `http://localhost:${port}`;
resolve();
}
});
});
afterAll(async () => {
// Clean up any remaining sessions
for (const sessionId of activeSessionIds) {
try {
await request(app).delete(`/api/sessions/${sessionId}`);
} catch (e) {
// Ignore errors during cleanup
}
}
// Close server
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
});
afterEach(async () => {
// Clean up sessions created in tests
const cleanupPromises = activeSessionIds.map((sessionId) =>
request(app)
.delete(`/api/sessions/${sessionId}`)
.catch(() => {})
);
await Promise.all(cleanupPromises);
activeSessionIds = [];
});
describe('Session Lifecycle', () => {
it('should create, list, and terminate a session', async () => {
// Create a session
const createResponse = await request(app)
.post('/api/sessions')
.send({
command: ['sh', '-c', 'echo "test" && sleep 0.1'],
workingDir: os.tmpdir(),
name: 'Integration Test Session',
})
.expect(200);
expect(createResponse.body).toHaveProperty('sessionId');
const sessionId = createResponse.body.sessionId;
activeSessionIds.push(sessionId);
expect(sessionId).toMatch(/^[a-f0-9-]+$/);
// List sessions and verify our session exists
const listResponse = await request(app).get('/api/sessions').expect(200);
expect(listResponse.body).toHaveProperty('sessions');
expect(Array.isArray(listResponse.body.sessions)).toBe(true);
const ourSession = listResponse.body.sessions.find((s: any) => s.id === sessionId);
expect(ourSession).toBeDefined();
expect(ourSession.command).toBe('sh -c echo "test" && sleep 0.1');
expect(ourSession.name).toBe('Integration Test Session');
expect(ourSession.workingDir).toBe(os.tmpdir());
expect(ourSession.status).toBe('running');
// Get session snapshot
const snapshotResponse = await request(app)
.get(`/api/sessions/${sessionId}/snapshot`)
.expect(200);
expect(snapshotResponse.body).toHaveProperty('lines');
expect(snapshotResponse.body).toHaveProperty('cursor');
expect(Array.isArray(snapshotResponse.body.lines)).toBe(true);
// Send input to session
const inputResponse = await request(app)
.post(`/api/sessions/${sessionId}/input`)
.send({ data: 'exit\n' })
.expect(200);
expect(inputResponse.body.message).toBe('Input sent');
// Wait a bit for the session to process
await new Promise((resolve) => setTimeout(resolve, 200));
// Terminate the session
const terminateResponse = await request(app).delete(`/api/sessions/${sessionId}`).expect(200);
expect(terminateResponse.body.message).toBe('Session terminated');
// Remove from active sessions
activeSessionIds = activeSessionIds.filter((id) => id !== sessionId);
});
it('should handle multiple concurrent sessions', async () => {
const sessionCount = 3;
const createPromises = [];
// Create multiple sessions
for (let i = 0; i < sessionCount; i++) {
const promise = request(app)
.post('/api/sessions')
.send({
command: ['sh', '-c', `echo "Session ${i}" && sleep 0.1`],
workingDir: os.tmpdir(),
name: `Test Session ${i}`,
});
createPromises.push(promise);
}
const createResponses = await Promise.all(createPromises);
const sessionIds = createResponses.map((res) => res.body.sessionId);
activeSessionIds.push(...sessionIds);
// Verify all sessions were created
expect(sessionIds).toHaveLength(sessionCount);
sessionIds.forEach((id) => expect(id).toMatch(/^[a-f0-9-]+$/));
// List all sessions
const listResponse = await request(app).get('/api/sessions').expect(200);
const activeSessions = listResponse.body.sessions.filter((s: any) =>
sessionIds.includes(s.id)
);
expect(activeSessions).toHaveLength(sessionCount);
// Clean up all sessions
const deletePromises = sessionIds.map((id) => request(app).delete(`/api/sessions/${id}`));
await Promise.all(deletePromises);
activeSessionIds = [];
});
it('should handle session exit and cleanup', async () => {
// Create a session that exits quickly
const createResponse = await request(app)
.post('/api/sessions')
.send({
command: ['sh', '-c', 'echo "Quick exit" && exit 0'],
workingDir: os.tmpdir(),
name: 'Quick Exit Session',
})
.expect(200);
const sessionId = createResponse.body.sessionId;
activeSessionIds.push(sessionId);
// Wait for session to exit
await new Promise((resolve) => setTimeout(resolve, 500));
// Check session status
const listResponse = await request(app).get('/api/sessions').expect(200);
const session = listResponse.body.sessions.find((s: any) => s.id === sessionId);
if (session) {
expect(session.status).toBe('exited');
expect(session.exitCode).toBe(0);
}
// Cleanup exited sessions
const cleanupResponse = await request(app).post('/api/cleanup-exited').expect(200);
expect(cleanupResponse.body.message).toBe('All exited sessions cleaned up');
// Verify session was cleaned up
const listAfterCleanup = await request(app).get('/api/sessions').expect(200);
const sessionAfterCleanup = listAfterCleanup.body.sessions.find(
(s: any) => s.id === sessionId
);
expect(sessionAfterCleanup).toBeUndefined();
activeSessionIds = activeSessionIds.filter((id) => id !== sessionId);
});
});
describe('Input/Output Operations', () => {
it('should handle terminal resize', async () => {
// Create a session
const createResponse = await request(app)
.post('/api/sessions')
.send({
command: ['sh'],
workingDir: os.tmpdir(),
name: 'Resize Test',
})
.expect(200);
const sessionId = createResponse.body.sessionId;
activeSessionIds.push(sessionId);
// Resize the terminal
const resizeResponse = await request(app)
.post(`/api/sessions/${sessionId}/resize`)
.send({ cols: 120, rows: 40 })
.expect(200);
expect(resizeResponse.body.message).toBe('Terminal resized');
// Clean up
await request(app).delete(`/api/sessions/${sessionId}`);
activeSessionIds = activeSessionIds.filter((id) => id !== sessionId);
});
it('should stream session output', async () => {
// Create a session that produces output
const createResponse = await request(app)
.post('/api/sessions')
.send({
command: ['sh', '-c', 'for i in 1 2 3; do echo "Line $i"; sleep 0.1; done'],
workingDir: os.tmpdir(),
name: 'Stream Test',
})
.expect(200);
const sessionId = createResponse.body.sessionId;
activeSessionIds.push(sessionId);
// Get the stream endpoint
const streamResponse = await request(app)
.get(`/api/sessions/${sessionId}/stream`)
.expect(200);
expect(streamResponse.headers['content-type']).toContain('text/event-stream');
// Clean up
await request(app).delete(`/api/sessions/${sessionId}`);
activeSessionIds = activeSessionIds.filter((id) => id !== sessionId);
});
});
describe('File System Operations', () => {
it('should browse directories', async () => {
const testDir = os.tmpdir();
const response = await request(app)
.get('/api/fs/browse')
.query({ path: testDir })
.expect(200);
expect(response.body).toHaveProperty('currentPath');
expect(response.body).toHaveProperty('parentPath');
expect(response.body).toHaveProperty('entries');
expect(Array.isArray(response.body.entries)).toBe(true);
expect(response.body.currentPath).toBe(testDir);
});
it('should create directories', async () => {
const parentDir = os.tmpdir();
const testDirName = `vibetunnel-test-${Date.now()}`;
const response = await request(app)
.post('/api/mkdir')
.send({
path: parentDir,
name: testDirName,
})
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.path).toContain(testDirName);
// Verify directory was created
const createdPath = path.join(parentDir, testDirName);
expect(fs.existsSync(createdPath)).toBe(true);
// Clean up
fs.rmdirSync(createdPath);
});
it('should handle directory creation errors', async () => {
// Try to create directory with invalid name
const response = await request(app)
.post('/api/mkdir')
.send({
path: os.tmpdir(),
name: '../../../etc/invalid',
})
.expect(400);
expect(response.body.error).toContain('Invalid directory name');
});
it('should prevent directory traversal', async () => {
const response = await request(app)
.get('/api/fs/browse')
.query({ path: '../../../etc/passwd' })
.expect(400);
expect(response.body.error).toContain('Invalid path');
});
});
describe('Error Handling', () => {
it('should handle invalid session IDs', async () => {
const invalidId = 'invalid-session-id';
const responses = await Promise.all([
request(app).get(`/api/sessions/${invalidId}/snapshot`).expect(404),
request(app).post(`/api/sessions/${invalidId}/input`).send({ data: 'test' }).expect(404),
request(app).delete(`/api/sessions/${invalidId}`).expect(404),
]);
responses.forEach((res) => {
expect(res.body).toHaveProperty('error');
});
});
it('should validate session creation parameters', async () => {
// Missing command
const missingCommand = await request(app)
.post('/api/sessions')
.send({ workingDir: os.tmpdir() })
.expect(400);
expect(missingCommand.body.error).toContain('Command array is required');
// Invalid command type
const invalidCommand = await request(app)
.post('/api/sessions')
.send({ command: 'not-an-array', workingDir: os.tmpdir() })
.expect(400);
expect(invalidCommand.body.error).toContain('Command array is required');
// Missing working directory
const missingDir = await request(app)
.post('/api/sessions')
.send({ command: ['ls'] })
.expect(400);
expect(missingDir.body.error).toContain('Working directory is required');
// Non-existent working directory
const invalidDir = await request(app)
.post('/api/sessions')
.send({ command: ['ls'], workingDir: '/non/existent/path' })
.expect(400);
expect(invalidDir.body.error).toContain('Working directory does not exist');
});
it('should handle command execution failures', async () => {
const response = await request(app)
.post('/api/sessions')
.send({
command: ['/non/existent/command'],
workingDir: os.tmpdir(),
})
.expect(200); // Session creation succeeds, but command will fail
const sessionId = response.body.sessionId;
activeSessionIds.push(sessionId);
// Wait for command to fail
await new Promise((resolve) => setTimeout(resolve, 500));
// Check session status
const listResponse = await request(app).get('/api/sessions').expect(200);
const session = listResponse.body.sessions.find((s: any) => s.id === sessionId);
if (session && session.status === 'exited') {
expect(session.exitCode).not.toBe(0);
}
});
});
describe('Cast File Generation', () => {
it('should generate test cast file', async () => {
const response = await request(app).get('/api/test-cast').expect(200);
expect(response.body).toHaveProperty('version');
expect(response.body).toHaveProperty('width');
expect(response.body).toHaveProperty('height');
expect(response.body).toHaveProperty('timestamp');
expect(response.body).toHaveProperty('events');
expect(response.body.version).toBe(2);
expect(Array.isArray(response.body.events)).toBe(true);
expect(response.body.events.length).toBeGreaterThan(0);
});
});
});

View file

@ -0,0 +1,180 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
import os from 'os';
describe('Basic Integration Test', () => {
describe('tty-fwd binary', () => {
it('should be available and executable', () => {
const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd');
expect(fs.existsSync(ttyFwdPath)).toBe(true);
// Check if executable
try {
fs.accessSync(ttyFwdPath, fs.constants.X_OK);
expect(true).toBe(true);
} catch (e) {
expect(e).toBeUndefined();
}
});
it('should show help information', async () => {
const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd');
const result = await new Promise<string>((resolve, reject) => {
const proc = spawn(ttyFwdPath, ['--help']);
let output = '';
proc.stdout.on('data', (data) => {
output += data.toString();
});
proc.stderr.on('data', (data) => {
output += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve(output);
} else {
reject(new Error(`Process exited with code ${code}: ${output}`));
}
});
});
expect(result).toContain('tty-fwd');
expect(result).toContain('Usage:');
});
it('should list sessions (empty)', async () => {
const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd');
const controlDir = path.join(os.tmpdir(), 'tty-fwd-test-' + Date.now());
// Create control directory
fs.mkdirSync(controlDir, { recursive: true });
try {
const result = await new Promise<string>((resolve, reject) => {
const proc = spawn(ttyFwdPath, ['--control-path', controlDir, '--list-sessions']);
let output = '';
proc.stdout.on('data', (data) => {
output += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve(output);
} else {
reject(new Error(`Process exited with code ${code}`));
}
});
});
// Should return empty JSON object for no sessions
const sessions = JSON.parse(result);
expect(typeof sessions).toBe('object');
expect(Object.keys(sessions)).toHaveLength(0);
} finally {
// Clean up
fs.rmSync(controlDir, { recursive: true, force: true });
}
});
it('should create and list a session', async () => {
const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd');
const controlDir = path.join(os.tmpdir(), 'tty-fwd-test-' + Date.now());
// Create control directory
fs.mkdirSync(controlDir, { recursive: true });
try {
// Create a session
const createResult = await new Promise<string>((resolve, reject) => {
const proc = spawn(ttyFwdPath, [
'--control-path',
controlDir,
'--session-name',
'Test Session',
'--',
'echo',
'Hello from tty-fwd',
]);
let output = '';
proc.stdout.on('data', (data) => {
output += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve(output.trim());
} else {
reject(new Error(`Process exited with code ${code}`));
}
});
});
// Should return a session ID (tty-fwd returns just the text output)
expect(createResult).toBeTruthy();
// List sessions
const listResult = await new Promise<string>((resolve, reject) => {
const proc = spawn(ttyFwdPath, ['--control-path', controlDir, '--list-sessions']);
let output = '';
proc.stdout.on('data', (data) => {
output += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve(output);
} else {
reject(new Error(`Process exited with code ${code}`));
}
});
});
// tty-fwd returns sessions as JSON object
const sessions = JSON.parse(listResult);
expect(typeof sessions).toBe('object');
// The session should be listed (note: tty-fwd might use a different key format)
const sessionKeys = Object.keys(sessions);
expect(sessionKeys.length).toBeGreaterThan(0);
} finally {
// Clean up
fs.rmSync(controlDir, { recursive: true, force: true });
}
});
});
describe('Server startup', () => {
it('should verify server dependencies exist', () => {
// Check that key files exist
const serverPath = path.resolve(__dirname, '../../server.ts');
const publicPath = path.resolve(__dirname, '../../../public');
// Debug paths
console.log('Looking for server at:', serverPath);
console.log('Server exists:', fs.existsSync(serverPath));
console.log('Looking for public at:', publicPath);
console.log('Public exists:', fs.existsSync(publicPath));
expect(fs.existsSync(serverPath)).toBe(true);
expect(fs.existsSync(publicPath)).toBe(true);
});
it('should load server module without crashing', async () => {
// Set up environment
process.env.NODE_ENV = 'test';
process.env.PORT = '0';
// This will test that the server module can be loaded
// In a real test, you'd start the server in a separate process
const serverPath = path.resolve(__dirname, '../../server.ts');
expect(fs.existsSync(serverPath)).toBe(true);
});
});
});

View file

@ -0,0 +1,209 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { Server } from 'http';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
// @ts-expect-error - TypeScript module imports in tests
import { app, server } from '../../server';
// Set up test environment
process.env.NODE_ENV = 'test';
process.env.PORT = '0';
const testControlDir = path.join(os.tmpdir(), 'vibetunnel-lifecycle-test', uuidv4());
process.env.TTY_FWD_CONTROL_DIR = testControlDir;
describe('Server Lifecycle Integration Tests', () => {
let port: number;
beforeAll(() => {
if (!fs.existsSync(testControlDir)) {
fs.mkdirSync(testControlDir, { recursive: true });
}
});
afterAll(() => {
if (fs.existsSync(testControlDir)) {
fs.rmSync(testControlDir, { recursive: true, force: true });
}
});
describe('Server Initialization', () => {
it('should start server and create control directory', async () => {
// Start server
await new Promise<void>((resolve) => {
if (!server.listening) {
server.listen(0, () => {
const address = server.address();
port = (address as any).port;
resolve();
});
} else {
const address = server.address();
port = (address as any).port;
resolve();
}
});
expect(port).toBeGreaterThan(0);
expect(server.listening).toBe(true);
// Verify control directory exists
expect(fs.existsSync(testControlDir)).toBe(true);
});
it('should serve static files', async () => {
// Test root route
const rootResponse = await request(app).get('/').expect(200);
expect(rootResponse.type).toContain('text/html');
expect(rootResponse.text).toContain('<!DOCTYPE html>');
// Test favicon
const faviconResponse = await request(app).get('/favicon.ico').expect(200);
expect(faviconResponse.type).toContain('image');
});
it('should handle 404 for non-existent routes', async () => {
const response = await request(app).get('/non-existent-route').expect(404);
expect(response.text).toContain('404');
});
it('should have all API endpoints available', async () => {
const endpoints = [
{ method: 'get', path: '/api/sessions' },
{ method: 'post', path: '/api/sessions' },
{ method: 'get', path: '/api/test-cast' },
{ method: 'get', path: '/api/fs/browse' },
{ method: 'post', path: '/api/mkdir' },
{ method: 'post', path: '/api/cleanup-exited' },
];
for (const endpoint of endpoints) {
const response = await request(app)[endpoint.method](endpoint.path);
// Should not return 404 (may return other errors like 400 for missing params)
expect(response.status).not.toBe(404);
}
});
});
describe('Middleware and Security', () => {
it('should parse JSON bodies', async () => {
const response = await request(app)
.post('/api/sessions')
.send({ test: 'data' })
.set('Content-Type', 'application/json')
.expect(400); // Will fail validation but should parse the body
expect(response.body).toHaveProperty('error');
});
it('should handle CORS headers', async () => {
const response = await request(app).get('/api/sessions').expect(200);
// In production, you might want to check for CORS headers
// For now, just verify the request succeeds
expect(response.body).toHaveProperty('sessions');
});
it('should handle large request bodies', async () => {
const largeCommand = Array(1000).fill('arg');
const response = await request(app)
.post('/api/sessions')
.send({
command: largeCommand,
workingDir: os.tmpdir(),
})
.expect(400); // Should fail but handle the large body
expect(response.body).toHaveProperty('error');
});
});
describe('Concurrent Request Handling', () => {
it('should handle multiple simultaneous requests', async () => {
const requestCount = 10;
const requests = [];
for (let i = 0; i < requestCount; i++) {
requests.push(request(app).get('/api/sessions'));
}
const responses = await Promise.all(requests);
// All requests should succeed
responses.forEach((response) => {
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('sessions');
});
});
it('should handle mixed read/write operations', async () => {
const operations = [
request(app).get('/api/sessions'),
request(app)
.post('/api/sessions')
.send({
command: ['echo', 'test1'],
workingDir: os.tmpdir(),
}),
request(app).get('/api/test-cast'),
request(app)
.post('/api/sessions')
.send({
command: ['echo', 'test2'],
workingDir: os.tmpdir(),
}),
request(app).get('/api/fs/browse').query({ path: os.tmpdir() }),
];
const responses = await Promise.all(operations);
// All operations should complete without errors
responses.forEach((response) => {
expect(response.status).toBeLessThan(500); // No server errors
});
// Clean up created sessions
const createdSessions = responses
.filter((r) => r.body && r.body.sessionId)
.map((r) => r.body.sessionId);
for (const sessionId of createdSessions) {
await request(app).delete(`/api/sessions/${sessionId}`);
}
});
});
describe('Server Shutdown', () => {
it('should close gracefully', async () => {
// Create a session that will be active
const createResponse = await request(app)
.post('/api/sessions')
.send({
command: ['sh', '-c', 'sleep 10'],
workingDir: os.tmpdir(),
})
.expect(200);
const sessionId = createResponse.body.sessionId;
// Close the server
await new Promise<void>((resolve) => {
server.close(() => {
resolve();
});
});
expect(server.listening).toBe(false);
// The session should be terminated (tty-fwd should handle this)
// In a real implementation, you might want to verify this
});
});
});

View file

@ -0,0 +1,243 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import { Server } from 'http';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { WebSocket } from 'ws';
// Set up test environment
process.env.NODE_ENV = 'test';
process.env.TTY_FWD_CONTROL_DIR = path.join(
os.tmpdir(),
'vibetunnel-server-test',
Date.now().toString()
);
// Create test control directory
const testControlDir = process.env.TTY_FWD_CONTROL_DIR;
if (!fs.existsSync(testControlDir)) {
fs.mkdirSync(testControlDir, { recursive: true });
}
describe('Server Integration Tests', () => {
let server: Server;
let app: any;
let port: number;
let baseUrl: string;
beforeAll(async () => {
// Import server after environment is set up
const serverModule = await import('../../server');
app = serverModule.app;
server = serverModule.server;
// Start server on random port
await new Promise<void>((resolve) => {
server = app.listen(0, () => {
const address = server.address();
port = (address as any).port;
baseUrl = `http://localhost:${port}`;
resolve();
});
});
});
afterAll(async () => {
// Close server
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
// Clean up test directory
if (fs.existsSync(testControlDir)) {
fs.rmSync(testControlDir, { recursive: true });
}
});
describe('API Endpoints', () => {
describe('GET /api/sessions', () => {
it('should return sessions list', async () => {
const response = await request(app).get('/api/sessions').expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
describe('POST /api/sessions', () => {
it('should validate command parameter', async () => {
const response = await request(app)
.post('/api/sessions')
.send({
command: 'not-an-array',
workingDir: process.cwd(),
})
.expect(400);
expect(response.body.error).toContain('Command array is required');
});
it('should validate working directory', async () => {
const response = await request(app)
.post('/api/sessions')
.send({
command: ['echo', 'test'],
})
.expect(400);
expect(response.body.error).toContain('Working directory is required');
});
it('should create session with valid parameters', async () => {
const response = await request(app)
.post('/api/sessions')
.send({
command: ['echo', 'hello'],
workingDir: process.cwd(),
name: 'Test Echo',
})
.expect(200);
expect(response.body).toHaveProperty('sessionId');
expect(response.body.sessionId).toBeTruthy();
});
});
describe('GET /api/fs/browse', () => {
it('should list directory contents', async () => {
const response = await request(app)
.get('/api/fs/browse')
.query({ path: process.cwd() })
.expect(200);
expect(response.body).toHaveProperty('entries');
expect(Array.isArray(response.body.entries)).toBe(true);
expect(response.body).toHaveProperty('currentPath');
});
it('should reject invalid paths', async () => {
const response = await request(app)
.get('/api/fs/browse')
.query({ path: '/nonexistent/path' })
.expect(404);
expect(response.body.error).toContain('Directory not found');
});
});
describe('POST /api/mkdir', () => {
it('should validate directory name', async () => {
const response = await request(app)
.post('/api/mkdir')
.send({
path: process.cwd(),
name: '../invalid',
})
.expect(400);
expect(response.body.error).toContain('Invalid directory name');
});
it('should create directory with valid name', async () => {
const testDirName = `test-dir-${Date.now()}`;
const response = await request(app)
.post('/api/mkdir')
.send({
path: process.cwd(),
name: testDirName,
})
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.path).toContain(testDirName);
// Clean up
const createdPath = path.join(process.cwd(), testDirName);
if (fs.existsSync(createdPath)) {
fs.rmdirSync(createdPath);
}
});
});
describe('GET /api/test-cast', () => {
it('should return test cast data', async () => {
// Skip this test if stream-out file doesn't exist
const testCastPath = path.join(__dirname, '../../../public/stream-out');
if (!fs.existsSync(testCastPath)) {
return;
}
const response = await request(app).get('/api/test-cast').expect(200);
// The endpoint returns plain text, not JSON
expect(response.type).toContain('text/plain');
});
});
});
describe('Static File Serving', () => {
it('should serve index.html for root path', async () => {
const response = await request(app).get('/').expect(200);
expect(response.type).toContain('text/html');
});
it('should handle 404 for non-existent files', async () => {
const response = await request(app).get('/non-existent-file.js').expect(404);
expect(response.text).toContain('404');
});
});
describe('WebSocket Connection', () => {
it('should accept WebSocket connections', (done) => {
const ws = new WebSocket(`ws://localhost:${port}?hotReload=true`);
ws.on('open', () => {
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
});
ws.on('close', () => {
done();
});
ws.on('error', (err) => {
done(err);
});
});
it('should reject non-hot-reload connections', (done) => {
const ws = new WebSocket(`ws://localhost:${port}`);
ws.on('close', (code, reason) => {
expect(code).toBe(1008);
expect(reason.toString()).toContain('Only hot reload connections supported');
done();
});
ws.on('error', () => {
// Expected to error
});
});
});
describe('Error Handling', () => {
it('should handle JSON parsing errors', async () => {
const response = await request(app)
.post('/api/sessions')
.set('Content-Type', 'application/json')
.send('invalid json')
.expect(400);
expect(response.status).toBe(400);
});
it('should handle server errors gracefully', async () => {
// Test with an endpoint that might fail
const response = await request(app).delete('/api/sessions/non-existent-session').expect(404);
expect(response.body).toHaveProperty('error');
});
});
});

View file

@ -0,0 +1,345 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { WebSocket } from 'ws';
import request from 'supertest';
import { Server } from 'http';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
// @ts-expect-error - TypeScript module imports in tests
import { app, server, wss } from '../../server';
// Set up test environment
process.env.NODE_ENV = 'test';
process.env.PORT = '0';
const testControlDir = path.join(os.tmpdir(), 'vibetunnel-ws-test', uuidv4());
process.env.TTY_FWD_CONTROL_DIR = testControlDir;
beforeAll(() => {
if (!fs.existsSync(testControlDir)) {
fs.mkdirSync(testControlDir, { recursive: true });
}
});
afterAll(() => {
if (fs.existsSync(testControlDir)) {
fs.rmSync(testControlDir, { recursive: true, force: true });
}
});
describe('WebSocket Integration Tests', () => {
let port: number;
let wsUrl: string;
let activeSessionIds: string[] = [];
beforeAll(async () => {
// Get server port
await new Promise<void>((resolve) => {
if (!server.listening) {
server.listen(0, () => {
const address = server.address();
port = (address as any).port;
wsUrl = `ws://localhost:${port}`;
resolve();
});
} else {
const address = server.address();
port = (address as any).port;
wsUrl = `ws://localhost:${port}`;
resolve();
}
});
});
afterAll(async () => {
// Clean up sessions
for (const sessionId of activeSessionIds) {
try {
await request(app).delete(`/api/sessions/${sessionId}`);
} catch (e) {
// Ignore
}
}
// Close all WebSocket connections
wss.clients.forEach((client: any) => {
if (client.readyState === WebSocket.OPEN) {
client.close();
}
});
// Close server
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
});
afterEach(() => {
// Clean up sessions after each test
activeSessionIds = [];
});
describe('Hot Reload WebSocket', () => {
it('should accept hot reload connections', (done) => {
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
ws.on('open', () => {
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
done();
});
ws.on('error', done);
});
it('should reject non-hot-reload connections', (done) => {
const ws = new WebSocket(wsUrl);
ws.on('close', (code, reason) => {
expect(code).toBe(1008);
expect(reason.toString()).toContain('Only hot reload connections supported');
done();
});
ws.on('error', () => {
// Expected
});
});
it('should handle multiple hot reload clients', async () => {
const clients: WebSocket[] = [];
const connectionPromises = [];
// Connect multiple clients
for (let i = 0; i < 3; i++) {
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
clients.push(ws);
const promise = new Promise<void>((resolve, reject) => {
ws.on('open', () => resolve());
ws.on('error', reject);
});
connectionPromises.push(promise);
}
await Promise.all(connectionPromises);
// All clients should be connected
expect(clients.every((ws) => ws.readyState === WebSocket.OPEN)).toBe(true);
// Clean up
clients.forEach((ws) => ws.close());
});
});
describe('Terminal Session WebSocket (Future)', () => {
// Note: The current server implementation only supports hot reload WebSockets
// These tests document the expected behavior for terminal session WebSockets
// when that functionality is implemented
it.skip('should subscribe to terminal session output', async () => {
// Create a session first
const createResponse = await request(app)
.post('/api/sessions')
.send({
command: ['sh', '-c', 'for i in 1 2 3; do echo "Line $i"; sleep 0.1; done'],
workingDir: os.tmpdir(),
name: 'WebSocket Test',
})
.expect(200);
const sessionId = createResponse.body.sessionId;
activeSessionIds.push(sessionId);
// Connect WebSocket and subscribe
const ws = new WebSocket(wsUrl);
const messages: any[] = [];
ws.on('message', (data) => {
messages.push(JSON.parse(data.toString()));
});
await new Promise<void>((resolve) => {
ws.on('open', () => {
// Subscribe to session
ws.send(
JSON.stringify({
type: 'subscribe',
sessionId: sessionId,
})
);
resolve();
});
});
// Wait for messages
await new Promise((resolve) => setTimeout(resolve, 1000));
// Should have received output
const outputMessages = messages.filter((m) => m.type === 'terminal-output');
expect(outputMessages.length).toBeGreaterThan(0);
ws.close();
});
it.skip('should handle terminal input via WebSocket', async () => {
// Create an interactive session
const createResponse = await request(app)
.post('/api/sessions')
.send({
command: ['sh'],
workingDir: os.tmpdir(),
name: 'Interactive Test',
})
.expect(200);
const sessionId = createResponse.body.sessionId;
activeSessionIds.push(sessionId);
// Connect and send input
const ws = new WebSocket(wsUrl);
await new Promise<void>((resolve) => {
ws.on('open', () => {
// Send input
ws.send(
JSON.stringify({
type: 'input',
sessionId: sessionId,
data: 'echo "Hello WebSocket"\n',
})
);
resolve();
});
});
// Wait for processing
await new Promise((resolve) => setTimeout(resolve, 500));
// Get snapshot to verify input was processed
const snapshotResponse = await request(app)
.get(`/api/sessions/${sessionId}/snapshot`)
.expect(200);
const output = snapshotResponse.body.lines.join('\n');
expect(output).toContain('Hello WebSocket');
ws.close();
});
it.skip('should handle terminal resize via WebSocket', async () => {
// Create a session
const createResponse = await request(app)
.post('/api/sessions')
.send({
command: ['sh'],
workingDir: os.tmpdir(),
name: 'Resize Test',
})
.expect(200);
const sessionId = createResponse.body.sessionId;
activeSessionIds.push(sessionId);
// Connect and resize
const ws = new WebSocket(wsUrl);
await new Promise<void>((resolve) => {
ws.on('open', () => {
// Send resize
ws.send(
JSON.stringify({
type: 'resize',
sessionId: sessionId,
cols: 120,
rows: 40,
})
);
resolve();
});
});
// Wait for processing
await new Promise((resolve) => setTimeout(resolve, 200));
// Verify resize (would need to check terminal dimensions)
ws.close();
});
});
describe('WebSocket Error Handling', () => {
it('should handle malformed messages gracefully', (done) => {
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
ws.on('open', () => {
// Send invalid JSON
ws.send('invalid json {');
// Should not crash the server
setTimeout(() => {
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
done();
}, 100);
});
ws.on('error', done);
});
it('should handle connection drops', async () => {
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
await new Promise<void>((resolve) => {
ws.on('open', resolve);
});
// Abruptly terminate connection
ws.terminate();
// Server should continue functioning
const response = await request(app).get('/api/sessions').expect(200);
expect(response.body).toHaveProperty('sessions');
});
});
describe('WebSocket Performance', () => {
it('should handle rapid message sending', async () => {
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
await new Promise<void>((resolve) => {
ws.on('open', resolve);
});
// Send many messages rapidly
const messageCount = 100;
for (let i = 0; i < messageCount; i++) {
ws.send(JSON.stringify({ type: 'test', index: i }));
}
// Should not crash or lose connection
await new Promise((resolve) => setTimeout(resolve, 500));
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
});
it('should handle large messages', async () => {
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
await new Promise<void>((resolve) => {
ws.on('open', resolve);
});
// Send a large message
const largeData = 'x'.repeat(1024 * 1024); // 1MB
ws.send(JSON.stringify({ type: 'test', data: largeData }));
// Should handle it without issues
await new Promise((resolve) => setTimeout(resolve, 200));
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
});
});
});

View file

@ -125,10 +125,10 @@ describe('Session Manager', () => {
const proc = mockSpawn('tty-fwd', args);
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', expect.arrayContaining([
'--env', 'NODE_ENV=test',
'--env', 'CUSTOM_VAR=value',
]));
expect(mockSpawn).toHaveBeenCalledWith(
'tty-fwd',
expect.arrayContaining(['--env', 'NODE_ENV=test', '--env', 'CUSTOM_VAR=value'])
);
});
it('should list all active sessions', async () => {
@ -308,12 +308,7 @@ describe('Session Manager', () => {
const proc = mockSpawn('tty-fwd', ['resize', sessionId, String(cols), String(rows)]);
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', [
'resize',
sessionId,
'120',
'40',
]);
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', ['resize', sessionId, '120', '40']);
});
it('should get terminal snapshot', async () => {

View file

@ -0,0 +1,43 @@
import { vi } from 'vitest';
// Set test environment
process.env.NODE_ENV = 'test';
process.env.VITEST_INTEGRATION = 'true';
// Don't mock dependencies for integration tests
// We want to test the real implementation
// Set up global test utilities
global.fetch = vi.fn();
// Increase timeout for integration tests
vi.setConfig({ testTimeout: 30000 });
// Clean up any leftover test data
import fs from 'fs';
import path from 'path';
import os from 'os';
const cleanupTestDirectories = () => {
const testDirPattern = /vibetunnel-(test|ws-test|lifecycle-test)/;
const tmpDir = os.tmpdir();
try {
const entries = fs.readdirSync(tmpDir);
entries.forEach((entry) => {
if (testDirPattern.test(entry)) {
const fullPath = path.join(tmpDir, entry);
try {
fs.rmSync(fullPath, { recursive: true, force: true });
} catch (e) {
// Ignore errors during cleanup
}
}
});
} catch (e) {
// Ignore errors
}
};
// Clean up before tests
cleanupTestDirectories();

View file

@ -3,19 +3,22 @@ import { vi } from 'vitest';
// Set test environment
process.env.NODE_ENV = 'test';
// Mock node-pty for tests since it requires native bindings
vi.mock('node-pty', () => ({
spawn: vi.fn(() => ({
pid: 12345,
process: 'mock-process',
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
on: vi.fn(),
onData: vi.fn(),
onExit: vi.fn(),
})),
}));
// Only mock node-pty for unit tests, not integration tests
if (!process.env.VITEST_INTEGRATION) {
// Mock node-pty for tests since it requires native bindings
vi.mock('node-pty', () => ({
spawn: vi.fn(() => ({
pid: 12345,
process: 'mock-process',
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
on: vi.fn(),
onData: vi.fn(),
onExit: vi.fn(),
})),
}));
}
// Set up global test utilities
global.fetch = vi.fn();

View file

@ -41,7 +41,7 @@ export const closeTestServer = async (server: Server): Promise<void> => {
};
export const waitForWebSocket = (ms: number = 100): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const mockWebSocketServer = () => {

View file

@ -0,0 +1,277 @@
import { describe, it, expect } from 'vitest';
// Session validation utilities that should be in the actual code
const validateSessionId = (id: any): boolean => {
return typeof id === 'string' && /^[a-f0-9-]+$/.test(id);
};
const validateCommand = (command: any): boolean => {
return (
Array.isArray(command) && command.length > 0 && command.every((arg) => typeof arg === 'string')
);
};
const validateWorkingDir = (dir: any): boolean => {
return typeof dir === 'string' && dir.length > 0 && !dir.includes('\0');
};
const sanitizePath = (path: string): string => {
// Remove null bytes and normalize
return path.replace(/\0/g, '').normalize();
};
const isValidSessionName = (name: any): boolean => {
return (
typeof name === 'string' &&
name.length > 0 &&
name.length <= 255 &&
!/[<>:"|?*\x00-\x1f]/.test(name)
);
};
describe('Session Validation', () => {
describe('validateSessionId', () => {
it('should accept valid session IDs', () => {
const validIds = [
'abc123',
'123e4567-e89b-12d3-a456-426614174000',
'session-1234',
'a1b2c3d4',
];
validIds.forEach((id) => {
expect(validateSessionId(id)).toBe(true);
});
});
it('should reject invalid session IDs', () => {
const invalidIds = [
'',
null,
undefined,
123,
'session with spaces',
'../../../etc/passwd',
'session;rm -rf /',
'session$variable',
'session`command`',
];
invalidIds.forEach((id) => {
expect(validateSessionId(id)).toBe(false);
});
});
});
describe('validateCommand', () => {
it('should accept valid commands', () => {
const validCommands = [
['bash'],
['ls', '-la'],
['node', 'app.js'],
['python', '-m', 'http.server', '8000'],
['vim', 'file.txt'],
];
validCommands.forEach((cmd) => {
expect(validateCommand(cmd)).toBe(true);
});
});
it('should reject invalid commands', () => {
const invalidCommands = [
[],
null,
undefined,
'bash',
[''],
[123],
[null],
['bash', null],
['bash', 123],
];
invalidCommands.forEach((cmd) => {
expect(validateCommand(cmd)).toBe(false);
});
});
});
describe('validateWorkingDir', () => {
it('should accept valid directories', () => {
const validDirs = [
'/home/user',
'/tmp',
'.',
'..',
'/home/user/projects/my-app',
'C:\\Users\\User\\Documents',
];
validDirs.forEach((dir) => {
expect(validateWorkingDir(dir)).toBe(true);
});
});
it('should reject invalid directories', () => {
const invalidDirs = ['', null, undefined, 123, '/path/with\0null', '\0/etc/passwd'];
invalidDirs.forEach((dir) => {
expect(validateWorkingDir(dir)).toBe(false);
});
});
});
describe('isValidSessionName', () => {
it('should accept valid session names', () => {
const validNames = [
'My Session',
'Project Build',
'test-123',
'Development Server',
'SSH to production',
];
validNames.forEach((name) => {
expect(isValidSessionName(name)).toBe(true);
});
});
it('should reject invalid session names', () => {
const invalidNames = [
'',
null,
undefined,
'a'.repeat(256),
'session<script>',
'session>redirect',
'session:colon',
'session"quote',
'session|pipe',
'session?question',
'session*asterisk',
'session\0null',
'session\x01control',
];
invalidNames.forEach((name) => {
expect(isValidSessionName(name)).toBe(false);
});
});
});
describe('sanitizePath', () => {
it('should remove null bytes', () => {
expect(sanitizePath('/path/with\0null')).toBe('/path/withnull');
expect(sanitizePath('\0/etc/passwd')).toBe('/etc/passwd');
expect(sanitizePath('file\0\0\0.txt')).toBe('file.txt');
});
it('should normalize paths', () => {
expect(sanitizePath('/path//to///file')).toBe('/path//to///file');
expect(sanitizePath('café.txt')).toBe('café.txt');
});
it('should handle clean paths', () => {
expect(sanitizePath('/home/user')).toBe('/home/user');
expect(sanitizePath('file.txt')).toBe('file.txt');
});
});
describe('Environment Variable Validation', () => {
const isValidEnvVar = (env: any): boolean => {
if (typeof env !== 'object' || env === null) return false;
for (const [key, value] of Object.entries(env)) {
// Key must be valid env var name
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return false;
// Value must be string
if (typeof value !== 'string') return false;
// No null bytes
if (value.includes('\0')) return false;
}
return true;
};
it('should accept valid environment variables', () => {
const validEnvs = [
{ PATH: '/usr/bin:/usr/local/bin' },
{ NODE_ENV: 'production' },
{ HOME: '/home/user', SHELL: '/bin/bash' },
{ API_KEY: 'secret123', PORT: '3000' },
];
validEnvs.forEach((env) => {
expect(isValidEnvVar(env)).toBe(true);
});
});
it('should reject invalid environment variables', () => {
const invalidEnvs = [
null,
undefined,
'not an object',
{ '': 'empty key' },
{ '123start': 'number start' },
{ 'has-dash': 'invalid char' },
{ 'has space': 'invalid char' },
{ valid: 123 },
{ valid: null },
{ valid: undefined },
{ valid: 'has\0null' },
];
invalidEnvs.forEach((env) => {
expect(isValidEnvVar(env)).toBe(false);
});
});
});
describe('Command Injection Prevention', () => {
const hasDangerousPatterns = (input: string): boolean => {
const dangerous = [
/[;&|`$(){}[\]<>]/, // Shell metacharacters
/\.\./, // Directory traversal
/\0/, // Null bytes
/\n|\r/, // Newlines
];
return dangerous.some((pattern) => pattern.test(input));
};
it('should detect dangerous patterns', () => {
const dangerous = [
'command; rm -rf /',
'command && evil',
'command || evil',
'command | evil',
'command `evil`',
'command $(evil)',
'command > /etc/passwd',
'command < /etc/shadow',
'../../../etc/passwd',
'file\0.txt',
'multi\nline',
];
dangerous.forEach((input) => {
expect(hasDangerousPatterns(input)).toBe(true);
});
});
it('should allow safe patterns', () => {
const safe = [
'normal-file.txt',
'my_session_123',
'/home/user/project',
'Project Name',
'test@example.com',
];
safe.forEach((input) => {
expect(hasDangerousPatterns(input)).toBe(false);
});
});
});
});

View file

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

View file

@ -162,10 +162,12 @@ describe('WebSocket Connection', () => {
await new Promise<void>((resolve) => ws.on('open', resolve));
// Subscribe to terminal
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
}));
ws.send(
JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
})
);
// Emit some data from the mock process
await waitForWebSocket(50);
@ -174,7 +176,7 @@ describe('WebSocket Connection', () => {
await waitForWebSocket(50);
// Check that we received the terminal output
const terminalMessages = messages.filter(m => m.type === 'terminal-output');
const terminalMessages = messages.filter((m) => m.type === 'terminal-output');
expect(terminalMessages.length).toBeGreaterThan(0);
expect(terminalMessages[0]).toMatchObject({
type: 'terminal-output',
@ -191,18 +193,22 @@ describe('WebSocket Connection', () => {
await new Promise<void>((resolve) => ws.on('open', resolve));
// Subscribe first
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
}));
ws.send(
JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
})
);
await waitForWebSocket(50);
// Then unsubscribe
ws.send(JSON.stringify({
type: 'unsubscribe',
sessionId: 'test-session-123',
}));
ws.send(
JSON.stringify({
type: 'unsubscribe',
sessionId: 'test-session-123',
})
);
await waitForWebSocket(50);
@ -218,12 +224,14 @@ describe('WebSocket Connection', () => {
await new Promise<void>((resolve) => ws.on('open', resolve));
// Send resize event
ws.send(JSON.stringify({
type: 'resize',
sessionId: 'test-session-123',
cols: 120,
rows: 40,
}));
ws.send(
JSON.stringify({
type: 'resize',
sessionId: 'test-session-123',
cols: 120,
rows: 40,
})
);
await waitForWebSocket(50);
@ -254,7 +262,7 @@ describe('WebSocket Connection', () => {
await waitForWebSocket(50);
// Should receive error message
const errorMessages = messages.filter(m => m.type === 'error');
const errorMessages = messages.filter((m) => m.type === 'error');
expect(errorMessages.length).toBe(1);
expect(errorMessages[0].error).toContain('Invalid message format');
@ -272,14 +280,16 @@ describe('WebSocket Connection', () => {
await new Promise<void>((resolve) => ws.on('open', resolve));
// Send subscribe without sessionId
ws.send(JSON.stringify({
type: 'subscribe',
}));
ws.send(
JSON.stringify({
type: 'subscribe',
})
);
await waitForWebSocket(50);
// Should receive error message
const errorMessages = messages.filter(m => m.type === 'error');
const errorMessages = messages.filter((m) => m.type === 'error');
expect(errorMessages.length).toBe(1);
expect(errorMessages[0].error).toContain('Session ID required');
@ -315,15 +325,17 @@ describe('WebSocket Connection', () => {
await new Promise<void>((resolve) => ws.on('open', resolve));
// Subscribe to terminal
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
}));
ws.send(
JSON.stringify({
type: 'subscribe',
sessionId: 'test-session-123',
})
);
await waitForWebSocket(100);
// Should receive error message
const errorMessages = messages.filter(m => m.type === 'error');
const errorMessages = messages.filter((m) => m.type === 'error');
expect(errorMessages.length).toBeGreaterThan(0);
expect(errorMessages[0].error).toContain('Failed to start stream');
@ -356,8 +368,8 @@ describe('WebSocket Connection', () => {
await waitForWebSocket(100);
// Both clients should receive session update
const updates1 = messages1.filter(m => m.type === 'sessions-updated');
const updates2 = messages2.filter(m => m.type === 'sessions-updated');
const updates1 = messages1.filter((m) => m.type === 'sessions-updated');
const updates2 = messages2.filter((m) => m.type === 'sessions-updated');
expect(updates1.length).toBeGreaterThan(0);
expect(updates2.length).toBeGreaterThan(0);

View file

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

View file

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