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
|
||||
// In nix 0.30, dup2 requires file descriptors, not raw integers
|
||||
use std::os::fd::{FromRawFd, OwnedFd, AsFd};
|
||||
use std::os::fd::{FromRawFd, OwnedFd};
|
||||
|
||||
// Create OwnedFd for slave_fd
|
||||
let slave_owned_fd = unsafe { OwnedFd::from_raw_fd(slave_fd) };
|
||||
|
|
|
|||
|
|
@ -595,7 +595,7 @@ fn spawn(mut opts: SpawnOptions) -> Result<i32, Error> {
|
|||
}
|
||||
|
||||
// Redirect stdin, stdout, stderr to the pty slave
|
||||
use std::os::fd::{FromRawFd, OwnedFd, AsFd};
|
||||
use std::os::fd::{FromRawFd, OwnedFd};
|
||||
let slave_fd = pty.slave.as_raw_fd();
|
||||
|
||||
// Create OwnedFd for slave and standard file descriptors
|
||||
|
|
@ -688,7 +688,7 @@ fn communication_loop(
|
|||
}
|
||||
|
||||
if read_fds.contains(stdin.as_fd()) {
|
||||
match read(stdin.as_raw_fd(), &mut buf) {
|
||||
match read(&stdin, &mut buf) {
|
||||
Ok(0) => {
|
||||
send_eof_sequence(master.as_fd());
|
||||
read_stdin = false;
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,9 @@ vi.mock('lit', () => ({
|
|||
connectedCallback() {}
|
||||
disconnectedCallback() {}
|
||||
requestUpdate() {}
|
||||
querySelector() { return null; }
|
||||
querySelector() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
html: (strings: TemplateStringsArray, ...values: any[]) => {
|
||||
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('$ '));
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
|
|||
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);
|
||||
|
||||
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 () => {
|
||||
|
|
|
|||
43
web/src/test/setup.integration.ts
Normal file
43
web/src/test/setup.integration.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { vi } from 'vitest';
|
||||
|
||||
// Set test environment
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.VITEST_INTEGRATION = 'true';
|
||||
|
||||
// Don't mock dependencies for integration tests
|
||||
// We want to test the real implementation
|
||||
|
||||
// Set up global test utilities
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// Increase timeout for integration tests
|
||||
vi.setConfig({ testTimeout: 30000 });
|
||||
|
||||
// Clean up any leftover test data
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const cleanupTestDirectories = () => {
|
||||
const testDirPattern = /vibetunnel-(test|ws-test|lifecycle-test)/;
|
||||
const tmpDir = os.tmpdir();
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(tmpDir);
|
||||
entries.forEach((entry) => {
|
||||
if (testDirPattern.test(entry)) {
|
||||
const fullPath = path.join(tmpDir, entry);
|
||||
try {
|
||||
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up before tests
|
||||
cleanupTestDirectories();
|
||||
|
|
@ -3,19 +3,22 @@ import { vi } from 'vitest';
|
|||
// Set test environment
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Mock node-pty for tests since it requires native bindings
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: vi.fn(() => ({
|
||||
pid: 12345,
|
||||
process: 'mock-process',
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
on: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
// Only mock node-pty for unit tests, not integration tests
|
||||
if (!process.env.VITEST_INTEGRATION) {
|
||||
// Mock node-pty for tests since it requires native bindings
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: vi.fn(() => ({
|
||||
pid: 12345,
|
||||
process: 'mock-process',
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
on: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
// Set up global test utilities
|
||||
global.fetch = vi.fn();
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
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));
|
||||
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ export default defineConfig({
|
|||
environment: 'node',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'html'],
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'src/test/',
|
||||
|
|
@ -15,8 +16,22 @@ export default defineConfig({
|
|||
'public/',
|
||||
'*.config.ts',
|
||||
'*.config.js',
|
||||
'**/*.test.ts',
|
||||
'**/*.spec.ts',
|
||||
],
|
||||
include: [
|
||||
'src/**/*.ts',
|
||||
'src/**/*.js',
|
||||
],
|
||||
all: true,
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
},
|
||||
testTimeout: 10000,
|
||||
// Separate test suites
|
||||
includeSource: ['src/**/*.{js,ts}'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
|
|
|||
42
web/vitest.integration.config.ts
Normal file
42
web/vitest.integration.config.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
setupFiles: ['./src/test/setup.integration.ts'],
|
||||
include: ['src/test/integration/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'src/test/',
|
||||
'dist/',
|
||||
'public/',
|
||||
'*.config.ts',
|
||||
'*.config.js',
|
||||
'**/*.test.ts',
|
||||
'**/*.spec.ts',
|
||||
],
|
||||
include: [
|
||||
'src/**/*.ts',
|
||||
'src/**/*.js',
|
||||
],
|
||||
all: true,
|
||||
},
|
||||
testTimeout: 30000, // Integration tests may take longer
|
||||
pool: 'forks', // Use separate processes for integration tests
|
||||
poolOptions: {
|
||||
forks: {
|
||||
singleFork: true, // Run tests sequentially to avoid port conflicts
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue