/** * @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, parsePaneList } from '../src/tmux-manager.js'; import { execSync } from 'node:child_process'; // ============================================================================ // 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 attach every session through the dedicated Codeman socket', () => { const args = manager.getAttachArgs('codeman-abc12345'); expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']); }); it('should attach registered sessions on the same dedicated socket (no per-session socket)', () => { manager.registerSession({ sessionId: 'some-session', muxName: 'codeman-abc12345', pid: 12345, createdAt: Date.now(), workingDir: '/tmp', mode: 'claude', attached: false, }); const args = manager.getAttachArgs('codeman-abc12345'); expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-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); }); }); // NOTE: In test mode (VITEST=1), sendInput is a no-op that returns true // without calling execSync. This prevents tests from sending input to real tmux. describe('sendInput (test mode safety)', () => { beforeEach(() => { manager.registerSession({ sessionId: 'test-id', muxName: 'codeman-1e571234', pid: 12345, createdAt: Date.now(), workingDir: '/tmp', mode: 'claude', attached: false, }); }); it('should return true for registered session (no-op in test mode)', async () => { expect(await manager.sendInput('test-id', '/clear\r')).toBe(true); }); it('should return false for unknown session', async () => { expect(await manager.sendInput('nonexistent', 'hello\r')).toBe(false); }); it('should not call any tmux commands in test mode', async () => { mockedExecSync.mockClear(); await manager.sendInput('test-id', 'hello\r'); const sendKeyCalls = mockedExecSync.mock.calls.filter( ([cmd]) => typeof cmd === 'string' && cmd.includes('send-keys') ); expect(sendKeyCalls).toHaveLength(0); }); }); // NOTE: In test mode, reconcileSessions returns all registered sessions as // alive without running any real tmux commands. This prevents discovery of // or interaction with the user's real tmux sessions. describe('reconcileSessions (test mode safety)', () => { it('should return all registered sessions as alive', async () => { manager.registerSession({ sessionId: 'alive-1', muxName: 'codeman-a11ce111', pid: 100, createdAt: Date.now(), workingDir: '/tmp', mode: 'claude', attached: false, }); const result = await manager.reconcileSessions(); expect(result.alive).toContain('alive-1'); expect(result.dead).toHaveLength(0); expect(result.discovered).toHaveLength(0); }); it('should never discover real tmux sessions', async () => { const result = await manager.reconcileSessions(); expect(result.discovered).toHaveLength(0); }); it('should not call any tmux commands in test mode', async () => { mockedExecSync.mockClear(); await manager.reconcileSessions(); const tmuxCalls = mockedExecSync.mock.calls.filter( ([cmd]) => typeof cmd === 'string' && (cmd.includes('has-session') || cmd.includes('list-sessions')) ); expect(tmuxCalls).toHaveLength(0); }); }); // NOTE: In test mode, killSession removes from memory without running any // real kill commands. The self-kill protection is not needed because no real // tmux commands are executed — sessions are only removed from the in-memory map. describe('killSession (test mode safety)', () => { it('should remove session from memory in test mode', async () => { manager.registerSession({ sessionId: 'kill-test', muxName: 'codeman-5e1f1111', pid: 999, createdAt: Date.now(), workingDir: '/tmp', mode: 'claude', attached: false, }); const result = await manager.killSession('kill-test'); expect(result).toBe(true); expect(manager.getSession('kill-test')).toBeUndefined(); }); it('should allow kill when session does NOT match CODEMAN_MUX_NAME', async () => { const originalEnv = process.env.CODEMAN_MUX_NAME; process.env.CODEMAN_MUX_NAME = 'codeman-0ther1111'; try { manager.registerSession({ sessionId: 'other-kill-test', muxName: 'codeman-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.CODEMAN_MUX_NAME; } else { process.env.CODEMAN_MUX_NAME = originalEnv; } } }); it('should allow kill when CODEMAN_MUX_NAME is not set', async () => { const originalEnv = process.env.CODEMAN_MUX_NAME; delete process.env.CODEMAN_MUX_NAME; try { manager.registerSession({ sessionId: 'no-env-test', muxName: 'codeman-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.CODEMAN_MUX_NAME; } else { process.env.CODEMAN_MUX_NAME = originalEnv; } } }); }); describe('metadata operations', () => { beforeEach(() => { manager.registerSession({ sessionId: 'meta-test', muxName: 'codeman-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: 'codeman-51111111', pid: 1, createdAt: Date.now(), workingDir: '/tmp', mode: 'claude', attached: false, }); manager.registerSession({ sessionId: 's2', muxName: 'codeman-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 }); }); }); // ============================================================================ // Parser Tests — locks in the '|' separator contract for `tmux list-panes -F` // output, guarding against regressions in non-tty execution contexts where // `\t` in tmux FORMAT strings can be emitted as the literal two characters // `\` + `t` instead of a tab byte (launchd, systemd without TTYPath, docker // exec without TTY). See PR #71. // ============================================================================ describe('parsePaneList', () => { it('parses well-formed output into name → pid', () => { const out = 'codeman-aaaa|1234\ncodeman-bbbb|5678\nclaudeman-cccc|9999'; const result = parsePaneList(out); expect(result.size).toBe(3); expect(result.get('codeman-aaaa')).toBe(1234); expect(result.get('codeman-bbbb')).toBe(5678); expect(result.get('claudeman-cccc')).toBe(9999); }); it('returns an empty map for empty output', () => { expect(parsePaneList('').size).toBe(0); }); it('skips blank lines', () => { const result = parsePaneList('\ncodeman-aaaa|100\n\n\ncodeman-bbbb|200\n'); expect(result.size).toBe(2); expect(result.get('codeman-aaaa')).toBe(100); expect(result.get('codeman-bbbb')).toBe(200); }); it('skips lines without the separator', () => { const result = parsePaneList('codeman-aaaa 1234\ncodeman-bbbb|5678'); expect(result.size).toBe(1); expect(result.get('codeman-bbbb')).toBe(5678); }); it('skips lines with a non-numeric pid', () => { const result = parsePaneList('codeman-aaaa|notapid\ncodeman-bbbb|5678'); expect(result.size).toBe(1); expect(result.get('codeman-bbbb')).toBe(5678); }); it('skips lines with an empty session name', () => { const result = parsePaneList('|1234\ncodeman-bbbb|5678'); expect(result.size).toBe(1); expect(result.get('codeman-bbbb')).toBe(5678); }); it('treats a literal backslash-t in input as part of the session name, not a delimiter', () => { // Reproduces the launchd/systemd regression: under non-tty contexts tmux // was emitting FORMAT '\t' as the two characters `\` + `t` rather than a // tab byte. With the '|' separator, such literals must not be silently // treated as a delimiter — the line is discarded because there is no '|'. const literalBackslashT = 'codeman-aaaa\\t1234'; const result = parsePaneList(literalBackslashT); expect(result.size).toBe(0); }); it('splits on the first separator only', () => { // Numeric trailing junk after the pid is tolerated by parseInt — proves // that splitting on the first '|' leaves the pid extractable even if a // future tmux ever appended extra fields. const result = parsePaneList('codeman-aaaa|1234|extra-field'); expect(result.get('codeman-aaaa')).toBe(1234); }); });