Files
Codeman/test/tmux-manager.test.ts
T
Tenggan ZhangandTeigen 1ff315a1e6 fix(tmux): isolate sessions on a dedicated socket + raise pane nofile limit (fixes new-session crash after tmux upgrade) (#96)
* fix: isolate codeman tmux sessions

* fix(tmux): unify all sessions onto a single dedicated socket

Remove the per-session `tmuxSocket` field that recorded which tmux server
each session lived on (default vs the `codeman` socket). That field was a
persisted cache of physical reality and could drift — causing live sessions
to be wrongly marked dead ("tab shows no session found") and spawning
duplicate "Restored:" tabs.

All Codeman sessions now live on one process-wide socket (`tmux -L codeman`,
overridable via CODEMAN_TMUX_SOCKET), exposed via TmuxManager.muxSocket on
the TerminalMultiplexer interface. reconcileSessions() collapses from a
multi-socket scan (locate / re-pin / cross-socket dedup) to a single
`list-panes` query. loadSessions() strips the obsolete field from on-disk
records so it stops being written back.

Also fix two sibling bare-`tmux` call sites the unification would otherwise
leave broken (same #80 regression class — bare tmux hits the user's default
server and never finds a session on the codeman socket):
- session.ts queryTmuxWindowSize(): add `-L <socket>` (was silently falling
  back to 120x40 on re-attach, losing scrollback)
- session-routes.ts send-key (Shift+Enter / Ctrl+Enter newline): route
  through ctx.mux.muxSocket

SSH chooser scripts (tmux-manager.sh, tmux-chooser.sh) route every tmux call
through `tmux -L $CODEMAN_TMUX_SOCKET`, matching the TS default.

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-25 23:28:58 +02:00

439 lines
14 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, 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);
});
});