Files
Codeman/test/tmux-manager.test.ts
T
2026-02-12 11:35:10 +01:00

666 lines
21 KiB
TypeScript

/**
* @fileoverview Unit + integration tests for TmuxManager
*
* Unit tests (mocked): validation, command construction, parsing logic.
* Integration tests (real tmux): session creation, input, kill, reconciliation.
*
* Port: N/A (no server needed)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TmuxManager } from '../src/tmux-manager.js';
import { execSync } from 'node:child_process';
import { registerTestTmuxSession, unregisterTestTmuxSession } from './setup.js';
// ============================================================================
// Unit Tests (mocked)
// ============================================================================
// Mock child_process
vi.mock('node:child_process', async () => {
const actual = await vi.importActual('node:child_process');
return {
...actual,
execSync: vi.fn(),
spawn: vi.fn(() => ({
unref: vi.fn(),
on: vi.fn(),
pid: 12345,
})),
};
});
// Mock fs to avoid file I/O
vi.mock('node:fs', async () => {
const actual = await vi.importActual('node:fs');
return {
...actual,
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn(),
mkdirSync: vi.fn(),
writeFile: vi.fn((_path: string, _data: string, cb: (err: Error | null) => void) => cb(null)),
};
});
describe('TmuxManager (unit)', () => {
let manager: TmuxManager;
const mockedExecSync = vi.mocked(execSync);
beforeEach(() => {
vi.clearAllMocks();
// Default: which claude returns /usr/local/bin/claude
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('which claude')) {
return '/usr/local/bin/claude\n';
}
if (typeof cmd === 'string' && cmd.includes('which tmux')) {
return '/usr/bin/tmux\n';
}
return '';
});
manager = new TmuxManager();
});
afterEach(() => {
manager.destroy();
});
describe('backend', () => {
it('should report tmux as backend', () => {
expect(manager.backend).toBe('tmux');
});
});
describe('getAttachCommand', () => {
it('should return tmux', () => {
expect(manager.getAttachCommand()).toBe('tmux');
});
});
describe('getAttachArgs', () => {
it('should return attach-session args', () => {
const args = manager.getAttachArgs('claudeman-abc12345');
expect(args).toEqual(['attach-session', '-t', 'claudeman-abc12345']);
});
});
describe('isAvailable', () => {
it('should return true when tmux is found', () => {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('which tmux')) {
return '/usr/bin/tmux\n';
}
return '';
});
expect(TmuxManager.isTmuxAvailable()).toBe(true);
});
it('should return false when tmux is not found', () => {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('which tmux')) {
throw new Error('not found');
}
return '';
});
expect(TmuxManager.isTmuxAvailable()).toBe(false);
});
});
describe('sendInput', () => {
beforeEach(() => {
// Register a session for sendInput tests
manager.registerSession({
sessionId: 'test-id',
muxName: 'claudeman-1e571234',
pid: 12345,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
});
it('should send text + Enter as two separate tmux commands', () => {
const calls: string[] = [];
mockedExecSync.mockImplementation((cmd: string) => {
const cmdStr = String(cmd);
if (cmdStr.includes('send-keys')) {
calls.push(cmdStr);
}
return '';
});
manager.sendInput('test-id', '/clear\r');
// Should have 2 calls: send-keys -l text, then send-keys Enter
expect(calls).toHaveLength(2);
expect(calls[0]).toContain('send-keys');
expect(calls[0]).toContain('-l');
expect(calls[0]).toContain('/clear');
expect(calls[1]).toContain('send-keys');
expect(calls[1]).toContain('Enter');
});
it('should send text only (no Enter) when no \\r present', () => {
const calls: string[] = [];
mockedExecSync.mockImplementation((cmd: string) => {
const cmdStr = String(cmd);
if (cmdStr.includes('send-keys')) {
calls.push(cmdStr);
}
return '';
});
manager.sendInput('test-id', 'hello world');
expect(calls).toHaveLength(1);
expect(calls[0]).toContain('send-keys');
expect(calls[0]).toContain('-l');
expect(calls[0]).not.toContain('Enter');
});
it('should send Enter only when input is just \\r', () => {
const calls: string[] = [];
mockedExecSync.mockImplementation((cmd: string) => {
const cmdStr = String(cmd);
if (cmdStr.includes('send-keys')) {
calls.push(cmdStr);
}
return '';
});
manager.sendInput('test-id', '\r');
expect(calls).toHaveLength(1);
expect(calls[0]).toContain('send-keys');
expect(calls[0]).toContain('Enter');
expect(calls[0]).not.toContain('-l');
});
it('should return false for unknown session', () => {
const result = manager.sendInput('nonexistent', 'hello\r');
expect(result).toBe(false);
});
it('should use -l flag for literal text (no key interpretation)', () => {
const calls: string[] = [];
mockedExecSync.mockImplementation((cmd: string) => {
const cmdStr = String(cmd);
if (cmdStr.includes('send-keys')) {
calls.push(cmdStr);
}
return '';
});
// Text that could be interpreted as tmux keys without -l
manager.sendInput('test-id', 'C-c');
expect(calls).toHaveLength(1);
expect(calls[0]).toContain('-l');
});
it('should target the correct session name', () => {
const calls: string[] = [];
mockedExecSync.mockImplementation((cmd: string) => {
const cmdStr = String(cmd);
if (cmdStr.includes('send-keys')) {
calls.push(cmdStr);
}
return '';
});
manager.sendInput('test-id', 'test\r');
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) {
expect(call).toContain('claudeman-1e571234');
}
});
});
describe('reconcileSessions', () => {
it('should detect alive sessions', async () => {
manager.registerSession({
sessionId: 'alive-1',
muxName: 'claudeman-a11ce111',
pid: 100,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('has-session')) {
return ''; // exit 0 = exists
}
if (typeof cmd === 'string' && cmd.includes('display-message')) {
return '100\n';
}
if (typeof cmd === 'string' && cmd.includes('list-sessions')) {
return 'claudeman-a11ce111\n';
}
return '';
});
const result = await manager.reconcileSessions();
expect(result.alive).toContain('alive-1');
expect(result.dead).toHaveLength(0);
});
it('should detect dead sessions', async () => {
manager.registerSession({
sessionId: 'dead-1',
muxName: 'claudeman-dead1111',
pid: 200,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('has-session')) {
throw new Error('session not found');
}
if (typeof cmd === 'string' && cmd.includes('list-sessions')) {
return ''; // no sessions
}
return '';
});
const result = await manager.reconcileSessions();
expect(result.dead).toContain('dead-1');
expect(result.alive).toHaveLength(0);
});
it('should discover unknown claudeman sessions', async () => {
// Use hex-only name to pass SAFE_MUX_NAME_PATTERN validation
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('list-sessions')) {
return 'claudeman-abc12345\nmy-other-session\n';
}
if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('abc12345')) {
return '999\n';
}
return '';
});
const result = await manager.reconcileSessions();
expect(result.discovered).toHaveLength(1);
expect(result.discovered[0]).toBe('restored-abc12345');
});
it('should not discover non-claudeman sessions', async () => {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('list-sessions')) {
return 'my-tmux-session\n';
}
return '';
});
const result = await manager.reconcileSessions();
expect(result.discovered).toHaveLength(0);
});
});
describe('killSession self-kill protection', () => {
it('should block kill when session matches CLAUDEMAN_SCREEN_NAME', async () => {
const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME;
process.env.CLAUDEMAN_SCREEN_NAME = 'claudeman-5e1f1111';
try {
manager.registerSession({
sessionId: 'self-kill-test',
muxName: 'claudeman-5e1f1111',
pid: 999,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
const result = await manager.killSession('self-kill-test');
expect(result).toBe(false);
// Session should still exist (not removed)
expect(manager.getSession('self-kill-test')).toBeDefined();
} finally {
if (originalEnv === undefined) {
delete process.env.CLAUDEMAN_SCREEN_NAME;
} else {
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
}
}
});
it('should allow kill when session does NOT match CLAUDEMAN_SCREEN_NAME', async () => {
const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME;
process.env.CLAUDEMAN_SCREEN_NAME = 'claudeman-0ther1111';
try {
manager.registerSession({
sessionId: 'other-kill-test',
muxName: 'claudeman-d1ff1111',
pid: 888,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
// Mock the kill flow
mockedExecSync.mockImplementation(() => '');
const result = await manager.killSession('other-kill-test');
expect(result).toBe(true);
// Session should be removed
expect(manager.getSession('other-kill-test')).toBeUndefined();
} finally {
if (originalEnv === undefined) {
delete process.env.CLAUDEMAN_SCREEN_NAME;
} else {
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
}
}
});
it('should allow kill when CLAUDEMAN_SCREEN_NAME is not set', async () => {
const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME;
delete process.env.CLAUDEMAN_SCREEN_NAME;
try {
manager.registerSession({
sessionId: 'no-env-test',
muxName: 'claudeman-aaa11111',
pid: 777,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
mockedExecSync.mockImplementation(() => '');
const result = await manager.killSession('no-env-test');
expect(result).toBe(true);
} finally {
if (originalEnv === undefined) {
delete process.env.CLAUDEMAN_SCREEN_NAME;
} else {
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
}
}
});
});
describe('metadata operations', () => {
beforeEach(() => {
manager.registerSession({
sessionId: 'meta-test',
muxName: 'claudeman-ae1a1234',
pid: 300,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
});
it('should update session name', () => {
const result = manager.updateSessionName('meta-test', 'My Session');
expect(result).toBe(true);
expect(manager.getSession('meta-test')?.name).toBe('My Session');
});
it('should return false for unknown session name update', () => {
const result = manager.updateSessionName('nonexistent', 'Name');
expect(result).toBe(false);
});
it('should set attached status', () => {
manager.setAttached('meta-test', true);
expect(manager.getSession('meta-test')?.attached).toBe(true);
manager.setAttached('meta-test', false);
expect(manager.getSession('meta-test')?.attached).toBe(false);
});
it('should update respawn config', () => {
const config = { enabled: true, idleTimeoutMs: 5000, updatePrompt: 'test', interStepDelayMs: 1000, sendClear: true, sendInit: true };
manager.updateRespawnConfig('meta-test', config);
expect(manager.getSession('meta-test')?.respawnConfig).toEqual(config);
});
it('should clear respawn config', () => {
manager.updateRespawnConfig('meta-test', { enabled: true, idleTimeoutMs: 5000, updatePrompt: 'test', interStepDelayMs: 1000, sendClear: true, sendInit: true });
manager.clearRespawnConfig('meta-test');
expect(manager.getSession('meta-test')?.respawnConfig).toBeUndefined();
});
it('should update ralph enabled', () => {
manager.updateRalphEnabled('meta-test', true);
expect(manager.getSession('meta-test')?.ralphEnabled).toBe(true);
});
});
describe('getSessions', () => {
it('should return all registered sessions', () => {
manager.registerSession({
sessionId: 's1',
muxName: 'claudeman-51111111',
pid: 1,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
manager.registerSession({
sessionId: 's2',
muxName: 'claudeman-52222222',
pid: 2,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'shell',
attached: true,
});
const sessions = manager.getSessions();
expect(sessions).toHaveLength(2);
expect(sessions.map(s => s.sessionId)).toContain('s1');
expect(sessions.map(s => s.sessionId)).toContain('s2');
});
});
describe('stats collection', () => {
it('should start and stop stats collection', () => {
manager.startStatsCollection(60000);
// No error thrown
manager.stopStatsCollection();
// No error thrown
});
});
});
// ============================================================================
// Integration Tests (real tmux sessions)
// ============================================================================
describe('TmuxManager (integration)', () => {
// Skip entire block if tmux is not available
const tmuxAvailable = (() => {
try {
const { execSync: realExecSync } = require('node:child_process');
realExecSync('which tmux', { encoding: 'utf-8', timeout: 5000 });
return true;
} catch {
return false;
}
})();
if (!tmuxAvailable) {
it.skip('tmux not available — skipping integration tests', () => {});
return;
}
// Real execSync for integration tests (bypasses mock)
const { execSync: realExecSync } = require('node:child_process') as typeof import('node:child_process');
// Helper: create a test tmux session directly via tmux CLI
function createRawTmuxSession(name: string): void {
realExecSync(`tmux new-session -ds "${name}" -x 80 -y 24 bash`, { timeout: 5000 });
registerTestTmuxSession(name);
}
// Helper: check if tmux session exists
function tmuxSessionExists(name: string): boolean {
try {
realExecSync(`tmux has-session -t "${name}" 2>/dev/null`, { timeout: 5000 });
return true;
} catch {
return false;
}
}
// Helper: kill a test tmux session directly
function killRawTmuxSession(name: string): void {
try {
realExecSync(`tmux kill-session -t "${name}" 2>/dev/null`, { timeout: 5000 });
} catch {
// May already be dead
}
unregisterTestTmuxSession(name);
}
// Track sessions created during integration tests for cleanup
const createdSessions: string[] = [];
afterEach(() => {
// Clean up any sessions created during the test
for (const name of createdSessions) {
killRawTmuxSession(name);
}
createdSessions.length = 0;
});
it('should create a real tmux session', () => {
const sessionName = 'claudeman-test-create';
createRawTmuxSession(sessionName);
createdSessions.push(sessionName);
expect(tmuxSessionExists(sessionName)).toBe(true);
});
it('should send input to a real tmux session and verify output', async () => {
const sessionName = 'claudeman-test-input';
createRawTmuxSession(sessionName);
createdSessions.push(sessionName);
// Send text to the session
realExecSync(`tmux send-keys -t "${sessionName}" -l 'echo TMUX_INPUT_TEST_OK'`, { timeout: 5000 });
realExecSync(`tmux send-keys -t "${sessionName}" Enter`, { timeout: 5000 });
// Wait for command to execute
await new Promise(resolve => setTimeout(resolve, 500));
// Capture pane contents
const output = realExecSync(`tmux capture-pane -t "${sessionName}" -p`, { encoding: 'utf-8', timeout: 5000 });
expect(output).toContain('TMUX_INPUT_TEST_OK');
});
it('should kill a real tmux session', () => {
const sessionName = 'claudeman-test-kill';
createRawTmuxSession(sessionName);
// Don't push to createdSessions since we'll kill it manually
expect(tmuxSessionExists(sessionName)).toBe(true);
realExecSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 5000 });
unregisterTestTmuxSession(sessionName);
expect(tmuxSessionExists(sessionName)).toBe(false);
});
it('should discover unknown claudeman sessions via reconcile', async () => {
// Create a tmux session directly (not via TmuxManager) — simulates a "ghost"
const sessionName = 'claudeman-te51abcd';
createRawTmuxSession(sessionName);
createdSessions.push(sessionName);
// existsSync is already mocked to return false (module-level mock),
// so TmuxManager won't load any persisted sessions from disk
const freshManager = new TmuxManager();
// Verify it doesn't know about the session yet
expect(freshManager.getSessions()).toHaveLength(0);
// Note: Full reconcile with real tmux requires unmocked execSync,
// which is covered by the tmux-restart-recovery.test.ts integration tests.
freshManager.destroy();
});
it('should verify self-kill protection with real env var', async () => {
const sessionName = 'claudeman-te515e1f';
createRawTmuxSession(sessionName);
createdSessions.push(sessionName);
const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME;
process.env.CLAUDEMAN_SCREEN_NAME = sessionName;
try {
// existsSync is already mocked to return false (module-level mock)
const testManager = new TmuxManager();
testManager.registerSession({
sessionId: 'self-test',
muxName: sessionName,
pid: 99999,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
// killSession should refuse
const result = await testManager.killSession('self-test');
expect(result).toBe(false);
// Session should still be alive in tmux
expect(tmuxSessionExists(sessionName)).toBe(true);
testManager.destroy();
} finally {
if (originalEnv === undefined) {
delete process.env.CLAUDEMAN_SCREEN_NAME;
} else {
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
}
}
});
it('should persist and load session metadata', () => {
// This test verifies the persistence format is correct by checking
// that registerSession + getSessions round-trips properly
// existsSync is already mocked to return false (module-level mock)
const manager1 = new TmuxManager();
manager1.registerSession({
sessionId: 'persist-test',
muxName: 'claudeman-be51aaa1',
pid: 12345,
createdAt: 1700000000000,
workingDir: '/home/test',
mode: 'claude',
attached: false,
name: 'Test Session',
respawnConfig: { enabled: true, idleTimeoutMs: 5000, updatePrompt: 'test', interStepDelayMs: 1000, sendClear: true, sendInit: true },
});
const sessions = manager1.getSessions();
expect(sessions).toHaveLength(1);
expect(sessions[0].sessionId).toBe('persist-test');
expect(sessions[0].name).toBe('Test Session');
expect(sessions[0].respawnConfig?.enabled).toBe(true);
manager1.destroy();
});
});