chore: bump version to 0.1485

This commit is contained in:
arkon
2026-02-12 11:35:10 +01:00
parent 336aff2d2c
commit 59a8f6bd7d
9 changed files with 600 additions and 17 deletions
+282 -4
View File
@@ -1,8 +1,8 @@
/**
* @fileoverview Unit tests for TmuxManager
* @fileoverview Unit + integration tests for TmuxManager
*
* Tests validation functions, command construction, and parsing logic
* using mocked exec calls. Does NOT create or kill real tmux sessions.
* Unit tests (mocked): validation, command construction, parsing logic.
* Integration tests (real tmux): session creation, input, kill, reconciliation.
*
* Port: N/A (no server needed)
*/
@@ -10,6 +10,11 @@
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 () => {
@@ -37,7 +42,7 @@ vi.mock('node:fs', async () => {
};
});
describe('TmuxManager', () => {
describe('TmuxManager (unit)', () => {
let manager: TmuxManager;
const mockedExecSync = vi.mocked(execSync);
@@ -299,6 +304,97 @@ describe('TmuxManager', () => {
});
});
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({
@@ -385,3 +481,185 @@ describe('TmuxManager', () => {
});
});
});
// ============================================================================
// 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();
});
});
+247
View File
@@ -0,0 +1,247 @@
/**
* @fileoverview Integration test for tmux session recovery after server restart.
*
* Tests the full restart cycle:
* 1. Create a WebServer with real tmux backend
* 2. Create a session (tmux)
* 3. Stop the server (without killing tmux sessions)
* 4. Start a new server instance
* 5. Verify session is recovered via reconcileSessions()
*
* Port: 3152
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { TmuxManager } from '../src/tmux-manager.js';
import { registerTestTmuxSession, unregisterTestTmuxSession } from './setup.js';
// Skip all tests if tmux is not available
const tmuxAvailable = (() => {
try {
execSync('which tmux', { encoding: 'utf-8', timeout: 5000 });
return true;
} catch {
return false;
}
})();
const MUX_SESSIONS_FILE = join(homedir(), '.claudeman', 'mux-sessions.json');
describe.skipIf(!tmuxAvailable)('TmuxManager restart recovery', () => {
// Track tmux sessions for cleanup
const createdTmuxSessions: string[] = [];
let originalMuxSessions: string | null = null;
beforeEach(() => {
// Back up the current mux-sessions.json if it exists
if (existsSync(MUX_SESSIONS_FILE)) {
originalMuxSessions = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
}
});
afterEach(async () => {
// Kill all tmux sessions created during tests
for (const name of createdTmuxSessions) {
try {
execSync(`tmux kill-session -t "${name}" 2>/dev/null`, { timeout: 5000 });
} catch {
// May already be dead
}
unregisterTestTmuxSession(name);
}
createdTmuxSessions.length = 0;
// Restore original mux-sessions.json
if (originalMuxSessions !== null) {
writeFileSync(MUX_SESSIONS_FILE, originalMuxSessions);
}
// Brief wait for cleanup
await new Promise(resolve => setTimeout(resolve, 200));
});
it('should recover a tmux session after manager restart', async () => {
// Step 1: Create a tmux session directly (simulating a session created by old server)
const sessionName = 'claudeman-de51ecaf';
execSync(`tmux new-session -ds "${sessionName}" -x 80 -y 24 bash`, { timeout: 5000 });
registerTestTmuxSession(sessionName);
createdTmuxSessions.push(sessionName);
// Step 2: Write session metadata to mux-sessions.json (as if old server persisted it)
const dir = join(homedir(), '.claudeman');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const sessionData = [{
sessionId: 'test-recovery-1',
muxName: sessionName,
pid: 1, // Stale PID — will be updated during reconcile
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
name: 'Recovery Test',
respawnConfig: { enabled: true, idleTimeoutMs: 10000, updatePrompt: 'continue', interStepDelayMs: 2000, sendClear: false, sendInit: true },
}];
writeFileSync(MUX_SESSIONS_FILE, JSON.stringify(sessionData, null, 2));
// Step 3: Create a new TmuxManager (simulates server restart — loads from file)
const manager = new TmuxManager();
// Verify session was loaded from disk
const loaded = manager.getSession('test-recovery-1');
expect(loaded).toBeDefined();
expect(loaded!.muxName).toBe(sessionName);
expect(loaded!.name).toBe('Recovery Test');
expect(loaded!.respawnConfig?.enabled).toBe(true);
// Step 4: Reconcile — should detect the tmux session is alive
const result = await manager.reconcileSessions();
expect(result.alive).toContain('test-recovery-1');
expect(result.dead).toHaveLength(0);
// PID should be updated to actual tmux pane PID
const reconciled = manager.getSession('test-recovery-1');
expect(reconciled).toBeDefined();
expect(reconciled!.pid).toBeGreaterThan(1); // Updated from stale PID
manager.destroy();
});
it('should detect dead sessions during reconcile', async () => {
// Write metadata for a session that doesn't actually exist in tmux
const dir = join(homedir(), '.claudeman');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const sessionData = [{
sessionId: 'test-dead-1',
muxName: 'claudeman-deadbeef',
pid: 99999,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
name: 'Dead Session',
}];
writeFileSync(MUX_SESSIONS_FILE, JSON.stringify(sessionData, null, 2));
const manager = new TmuxManager();
// Verify session was loaded
expect(manager.getSession('test-dead-1')).toBeDefined();
// Reconcile — should detect the session is dead
const result = await manager.reconcileSessions();
expect(result.dead).toContain('test-dead-1');
expect(result.alive).not.toContain('test-dead-1');
// Session should be removed after reconcile
expect(manager.getSession('test-dead-1')).toBeUndefined();
manager.destroy();
});
it('should discover ghost sessions not in metadata', async () => {
// Create a tmux session directly (not via TmuxManager, no metadata)
const sessionName = 'claudeman-ab12ef34';
execSync(`tmux new-session -ds "${sessionName}" -x 80 -y 24 bash`, { timeout: 5000 });
registerTestTmuxSession(sessionName);
createdTmuxSessions.push(sessionName);
// Start with empty metadata
const dir = join(homedir(), '.claudeman');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(MUX_SESSIONS_FILE, '[]');
const manager = new TmuxManager();
// No sessions loaded
expect(manager.getSessions()).toHaveLength(0);
// Reconcile should discover the ghost session
const result = await manager.reconcileSessions();
expect(result.discovered.length).toBeGreaterThanOrEqual(1);
// Find the discovered session
const discoveredId = result.discovered.find(id => id === 'restored-ab12ef34');
expect(discoveredId).toBeDefined();
// Verify the discovered session has correct metadata
const session = manager.getSession(discoveredId!);
expect(session).toBeDefined();
expect(session!.muxName).toBe(sessionName);
expect(session!.mode).toBe('claude');
expect(session!.pid).toBeGreaterThan(0);
manager.destroy();
});
it('should handle mixed alive, dead, and ghost sessions', async () => {
// Create a real tmux session (will be "alive")
const aliveSessionName = 'claudeman-a11eeaaa';
execSync(`tmux new-session -ds "${aliveSessionName}" -x 80 -y 24 bash`, { timeout: 5000 });
registerTestTmuxSession(aliveSessionName);
createdTmuxSessions.push(aliveSessionName);
// Create a ghost session (real tmux, no metadata)
const ghostSessionName = 'claudeman-ab05fabf';
execSync(`tmux new-session -ds "${ghostSessionName}" -x 80 -y 24 bash`, { timeout: 5000 });
registerTestTmuxSession(ghostSessionName);
createdTmuxSessions.push(ghostSessionName);
// Write metadata with alive + dead sessions
const dir = join(homedir(), '.claudeman');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const sessionData = [
{
sessionId: 'alive-session',
muxName: aliveSessionName,
pid: 1,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
name: 'Alive Session',
},
{
sessionId: 'dead-session',
muxName: 'claudeman-dead0000',
pid: 99999,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
name: 'Dead Session',
},
];
writeFileSync(MUX_SESSIONS_FILE, JSON.stringify(sessionData, null, 2));
const manager = new TmuxManager();
const result = await manager.reconcileSessions();
expect(result.alive).toContain('alive-session');
expect(result.dead).toContain('dead-session');
expect(result.discovered).toContain('restored-ab05fabf');
// Verify final session state
const sessions = manager.getSessions();
const sessionIds = sessions.map(s => s.sessionId);
expect(sessionIds).toContain('alive-session');
expect(sessionIds).not.toContain('dead-session');
expect(sessionIds).toContain('restored-ab05fabf');
manager.destroy();
});
});