Files
automaker/apps/server/tests/unit/services/terminal-service.test.ts
Test User 59bbbd43c5 feat: add Node.js version management and improve error handling
- Introduced a .nvmrc file to specify the Node.js version (22) for the project, ensuring consistent development environments.
- Enhanced error handling in the startServer function to provide clearer messages when the Node.js executable cannot be found, improving debugging experience.
- Updated package.json files across various modules to enforce Node.js version compatibility and ensure consistent dependency versions.

These changes aim to streamline development processes and enhance the application's reliability by enforcing version control and improving error reporting.
2025-12-31 18:42:33 -05:00

605 lines
21 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TerminalService, getTerminalService } from '@/services/terminal-service.js';
import * as pty from 'node-pty';
import * as os from 'os';
import * as platform from '@automaker/platform';
import * as secureFs from '@/lib/secure-fs.js';
vi.mock('node-pty');
vi.mock('os');
vi.mock('@automaker/platform', async () => {
const actual = await vi.importActual('@automaker/platform');
return {
...actual,
systemPathExists: vi.fn(),
systemPathReadFileSync: vi.fn(),
getWslVersionPath: vi.fn(),
isAllowedSystemPath: vi.fn(() => true), // Allow all paths in tests
};
});
vi.mock('@/lib/secure-fs.js');
describe('terminal-service.ts', () => {
let service: TerminalService;
let mockPtyProcess: any;
beforeEach(() => {
vi.clearAllMocks();
service = new TerminalService();
// Mock PTY process
mockPtyProcess = {
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess);
vi.mocked(os.homedir).mockReturnValue('/home/user');
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(os.arch).mockReturnValue('x64');
// Default mocks for system paths and secureFs
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(platform.systemPathReadFileSync).mockReturnValue('');
vi.mocked(platform.getWslVersionPath).mockReturnValue('/proc/version');
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
});
afterEach(() => {
service.cleanup();
});
describe('detectShell', () => {
it('should detect PowerShell Core on Windows when available', () => {
vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
});
const result = service.detectShell();
expect(result.shell).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe');
expect(result.args).toEqual([]);
});
it('should fall back to PowerShell on Windows if Core not available', () => {
vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
});
const result = service.detectShell();
expect(result.shell).toBe('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe');
expect(result.args).toEqual([]);
});
it('should fall back to cmd.exe on Windows if no PowerShell', () => {
vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(platform.systemPathExists).mockReturnValue(false);
const result = service.detectShell();
expect(result.shell).toBe('cmd.exe');
expect(result.args).toEqual([]);
});
it('should detect user shell on macOS', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/zsh' });
vi.mocked(platform.systemPathExists).mockReturnValue(true);
const result = service.detectShell();
expect(result.shell).toBe('/bin/zsh');
expect(result.args).toEqual(['--login']);
});
it('should fall back to zsh on macOS if user shell not available', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
return path === '/bin/zsh';
});
const result = service.detectShell();
expect(result.shell).toBe('/bin/zsh');
expect(result.args).toEqual(['--login']);
});
it('should fall back to bash on macOS if zsh not available', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(platform.systemPathExists).mockReturnValue(false);
const result = service.detectShell();
expect(result.shell).toBe('/bin/bash');
expect(result.args).toEqual(['--login']);
});
it('should detect user shell on Linux', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
vi.mocked(platform.systemPathExists).mockReturnValue(true);
const result = service.detectShell();
expect(result.shell).toBe('/bin/bash');
expect(result.args).toEqual(['--login']);
});
it('should fall back to bash on Linux if user shell not available', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
return path === '/bin/bash';
});
const result = service.detectShell();
expect(result.shell).toBe('/bin/bash');
expect(result.args).toEqual(['--login']);
});
it('should fall back to sh on Linux if bash not available', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(platform.systemPathExists).mockReturnValue(false);
const result = service.detectShell();
expect(result.shell).toBe('/bin/sh');
expect(result.args).toEqual([]);
});
it('should detect WSL and use appropriate shell', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(platform.systemPathReadFileSync).mockReturnValue(
'Linux version 5.10.0-microsoft-standard-WSL2'
);
const result = service.detectShell();
expect(result.shell).toBe('/bin/bash');
expect(result.args).toEqual(['--login']);
});
});
describe('isWSL', () => {
it('should return true if /proc/version contains microsoft', () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(platform.systemPathReadFileSync).mockReturnValue(
'Linux version 5.10.0-microsoft-standard-WSL2'
);
expect(service.isWSL()).toBe(true);
});
it('should return true if /proc/version contains wsl', () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(platform.systemPathReadFileSync).mockReturnValue('Linux version 5.10.0-wsl2');
expect(service.isWSL()).toBe(true);
});
it('should return true if WSL_DISTRO_NAME is set', () => {
vi.mocked(platform.systemPathExists).mockReturnValue(false);
vi.spyOn(process, 'env', 'get').mockReturnValue({ WSL_DISTRO_NAME: 'Ubuntu' });
expect(service.isWSL()).toBe(true);
});
it('should return true if WSLENV is set', () => {
vi.mocked(platform.systemPathExists).mockReturnValue(false);
vi.spyOn(process, 'env', 'get').mockReturnValue({ WSLENV: 'PATH/l' });
expect(service.isWSL()).toBe(true);
});
it('should return false if not in WSL', () => {
vi.mocked(platform.systemPathExists).mockReturnValue(false);
vi.spyOn(process, 'env', 'get').mockReturnValue({});
expect(service.isWSL()).toBe(false);
});
it('should return false if error reading /proc/version', () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(platform.systemPathReadFileSync).mockImplementation(() => {
throw new Error('Permission denied');
});
expect(service.isWSL()).toBe(false);
});
});
describe('getPlatformInfo', () => {
it('should return platform information', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(os.arch).mockReturnValue('x64');
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const info = service.getPlatformInfo();
expect(info.platform).toBe('linux');
expect(info.arch).toBe('x64');
expect(info.defaultShell).toBe('/bin/bash');
expect(typeof info.isWSL).toBe('boolean');
});
});
describe('createSession', () => {
it('should create a new terminal session', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = await service.createSession({
cwd: '/test/dir',
cols: 100,
rows: 30,
});
expect(session).not.toBeNull();
expect(session!.id).toMatch(/^term-/);
expect(session!.cwd).toBe('/test/dir');
expect(session!.shell).toBe('/bin/bash');
expect(pty.spawn).toHaveBeenCalledWith(
'/bin/bash',
['--login'],
expect.objectContaining({
cwd: '/test/dir',
cols: 100,
rows: 30,
})
);
});
it('should use default cols and rows if not provided', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
await service.createSession();
expect(pty.spawn).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({
cols: 80,
rows: 24,
})
);
});
it('should fall back to home directory if cwd does not exist', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockRejectedValue(new Error('ENOENT'));
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = await service.createSession({
cwd: '/nonexistent',
});
expect(session).not.toBeNull();
expect(session!.cwd).toBe('/home/user');
});
it('should fall back to home directory if cwd is not a directory', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => false } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = await service.createSession({
cwd: '/file.txt',
});
expect(session).not.toBeNull();
expect(session!.cwd).toBe('/home/user');
});
it('should fix double slashes in path', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = await service.createSession({
cwd: '//test/dir',
});
expect(session).not.toBeNull();
expect(session!.cwd).toBe('/test/dir');
});
it('should preserve WSL UNC paths', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = await service.createSession({
cwd: '//wsl$/Ubuntu/home',
});
expect(session).not.toBeNull();
expect(session!.cwd).toBe('//wsl$/Ubuntu/home');
});
it('should handle data events from PTY', async () => {
vi.useFakeTimers();
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const dataCallback = vi.fn();
service.onData(dataCallback);
await service.createSession();
// Simulate data event
const onDataHandler = mockPtyProcess.onData.mock.calls[0][0];
onDataHandler('test data');
// Wait for throttled output
vi.advanceTimersByTime(20);
expect(dataCallback).toHaveBeenCalled();
vi.useRealTimers();
});
it('should handle exit events from PTY', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const exitCallback = vi.fn();
service.onExit(exitCallback);
const session = await service.createSession();
// Simulate exit event
const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0];
onExitHandler({ exitCode: 0 });
expect(session).not.toBeNull();
expect(exitCallback).toHaveBeenCalledWith(session!.id, 0);
expect(service.getSession(session!.id)).toBeUndefined();
});
});
describe('write', () => {
it('should write data to existing session', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = await service.createSession();
const result = service.write(session!.id, 'ls\n');
expect(result).toBe(true);
expect(mockPtyProcess.write).toHaveBeenCalledWith('ls\n');
});
it('should return false for non-existent session', () => {
const result = service.write('nonexistent', 'data');
expect(result).toBe(false);
expect(mockPtyProcess.write).not.toHaveBeenCalled();
});
});
describe('resize', () => {
it('should resize existing session', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = await service.createSession();
const result = service.resize(session!.id, 120, 40);
expect(result).toBe(true);
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
});
it('should return false for non-existent session', () => {
const result = service.resize('nonexistent', 120, 40);
expect(result).toBe(false);
expect(mockPtyProcess.resize).not.toHaveBeenCalled();
});
it('should handle resize errors', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
mockPtyProcess.resize.mockImplementation(() => {
throw new Error('Resize failed');
});
const session = await service.createSession();
const result = service.resize(session!.id, 120, 40);
expect(result).toBe(false);
});
});
describe('killSession', () => {
it('should kill existing session', async () => {
vi.useFakeTimers();
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = await service.createSession();
const result = service.killSession(session!.id);
expect(result).toBe(true);
expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGTERM');
// Session is removed after SIGKILL timeout (1 second)
vi.advanceTimersByTime(1000);
expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGKILL');
expect(service.getSession(session!.id)).toBeUndefined();
vi.useRealTimers();
});
it('should return false for non-existent session', () => {
const result = service.killSession('nonexistent');
expect(result).toBe(false);
});
it('should handle kill errors', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
mockPtyProcess.kill.mockImplementation(() => {
throw new Error('Kill failed');
});
const session = await service.createSession();
const result = service.killSession(session!.id);
expect(result).toBe(false);
});
});
describe('getSession', () => {
it('should return existing session', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = await service.createSession();
const retrieved = service.getSession(session!.id);
expect(retrieved).toBe(session);
});
it('should return undefined for non-existent session', () => {
const retrieved = service.getSession('nonexistent');
expect(retrieved).toBeUndefined();
});
});
describe('getScrollback', () => {
it('should return scrollback buffer for existing session', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = await service.createSession();
session!.scrollbackBuffer = 'test scrollback';
const scrollback = service.getScrollback(session!.id);
expect(scrollback).toBe('test scrollback');
});
it('should return null for non-existent session', () => {
const scrollback = service.getScrollback('nonexistent');
expect(scrollback).toBeNull();
});
});
describe('getAllSessions', () => {
it('should return all active sessions', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session1 = await service.createSession({ cwd: '/dir1' });
const session2 = await service.createSession({ cwd: '/dir2' });
const sessions = service.getAllSessions();
expect(sessions).toHaveLength(2);
expect(session1).not.toBeNull();
expect(session2).not.toBeNull();
expect(sessions[0].id).toBe(session1!.id);
expect(sessions[1].id).toBe(session2!.id);
expect(sessions[0].cwd).toBe('/dir1');
expect(sessions[1].cwd).toBe('/dir2');
});
it('should return empty array if no sessions', () => {
const sessions = service.getAllSessions();
expect(sessions).toEqual([]);
});
});
describe('onData and onExit', () => {
it('should allow subscribing and unsubscribing from data events', () => {
const callback = vi.fn();
const unsubscribe = service.onData(callback);
expect(typeof unsubscribe).toBe('function');
unsubscribe();
});
it('should allow subscribing and unsubscribing from exit events', () => {
const callback = vi.fn();
const unsubscribe = service.onExit(callback);
expect(typeof unsubscribe).toBe('function');
unsubscribe();
});
});
describe('cleanup', () => {
it('should clean up all sessions', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session1 = await service.createSession();
const session2 = await service.createSession();
service.cleanup();
expect(session1).not.toBeNull();
expect(session2).not.toBeNull();
expect(service.getSession(session1!.id)).toBeUndefined();
expect(service.getSession(session2!.id)).toBeUndefined();
expect(service.getAllSessions()).toHaveLength(0);
});
it('should handle cleanup errors gracefully', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
mockPtyProcess.kill.mockImplementation(() => {
throw new Error('Kill failed');
});
await service.createSession();
expect(() => service.cleanup()).not.toThrow();
});
});
describe('getTerminalService', () => {
it('should return singleton instance', () => {
const instance1 = getTerminalService();
const instance2 = getTerminalService();
expect(instance1).toBe(instance2);
});
});
});