mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
chore: bump version to 0.1481
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for mux-factory
|
||||
*
|
||||
* Tests detection logic with mocked `which` commands.
|
||||
* Tests CLAUDEMAN_MUX env var override.
|
||||
* Tests error when neither multiplexer is available.
|
||||
*
|
||||
* Port: N/A (no server needed)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createMultiplexer } from '../src/mux-factory.js';
|
||||
import { ScreenManager } from '../src/screen-manager.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
|
||||
// Spy on static availability methods
|
||||
const tmuxAvailableSpy = vi.spyOn(TmuxManager, 'isTmuxAvailable');
|
||||
const screenAvailableSpy = vi.spyOn(ScreenManager, 'isScreenAvailable');
|
||||
|
||||
// Mock child_process for TmuxManager constructor (it calls execSync for 'which claude')
|
||||
vi.mock('node:child_process', async () => {
|
||||
const actual = await vi.importActual('node:child_process');
|
||||
return {
|
||||
...actual,
|
||||
execSync: vi.fn((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';
|
||||
}
|
||||
if (typeof cmd === 'string' && cmd.includes('which screen')) {
|
||||
return '/usr/bin/screen\n';
|
||||
}
|
||||
return '';
|
||||
}),
|
||||
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)),
|
||||
writeFileSync: vi.fn(),
|
||||
readdirSync: vi.fn().mockReturnValue([]),
|
||||
unlinkSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('createMultiplexer', () => {
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
originalEnv = process.env.CLAUDEMAN_MUX;
|
||||
delete process.env.CLAUDEMAN_MUX;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.CLAUDEMAN_MUX = originalEnv;
|
||||
} else {
|
||||
delete process.env.CLAUDEMAN_MUX;
|
||||
}
|
||||
});
|
||||
|
||||
describe('auto-detection', () => {
|
||||
it('should prefer tmux when both are available', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(true);
|
||||
screenAvailableSpy.mockReturnValue(true);
|
||||
|
||||
const mux = createMultiplexer();
|
||||
expect(mux.backend).toBe('tmux');
|
||||
mux.destroy();
|
||||
});
|
||||
|
||||
it('should fall back to screen when tmux is not available', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(false);
|
||||
screenAvailableSpy.mockReturnValue(true);
|
||||
|
||||
const mux = createMultiplexer();
|
||||
expect(mux.backend).toBe('screen');
|
||||
mux.destroy();
|
||||
});
|
||||
|
||||
it('should throw when neither is available', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(false);
|
||||
screenAvailableSpy.mockReturnValue(false);
|
||||
|
||||
expect(() => createMultiplexer()).toThrow('No terminal multiplexer found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forced parameter', () => {
|
||||
it('should use tmux when forced', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(true);
|
||||
screenAvailableSpy.mockReturnValue(true);
|
||||
|
||||
const mux = createMultiplexer('tmux');
|
||||
expect(mux.backend).toBe('tmux');
|
||||
mux.destroy();
|
||||
});
|
||||
|
||||
it('should use screen when forced', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(true);
|
||||
screenAvailableSpy.mockReturnValue(true);
|
||||
|
||||
const mux = createMultiplexer('screen');
|
||||
expect(mux.backend).toBe('screen');
|
||||
mux.destroy();
|
||||
});
|
||||
|
||||
it('should throw when forced tmux is not available', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(false);
|
||||
|
||||
expect(() => createMultiplexer('tmux')).toThrow('tmux requested');
|
||||
});
|
||||
|
||||
it('should throw when forced screen is not available', () => {
|
||||
screenAvailableSpy.mockReturnValue(false);
|
||||
|
||||
expect(() => createMultiplexer('screen')).toThrow('Screen requested');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLAUDEMAN_MUX env var', () => {
|
||||
it('should respect CLAUDEMAN_MUX=screen', () => {
|
||||
process.env.CLAUDEMAN_MUX = 'screen';
|
||||
tmuxAvailableSpy.mockReturnValue(true);
|
||||
screenAvailableSpy.mockReturnValue(true);
|
||||
|
||||
const mux = createMultiplexer();
|
||||
expect(mux.backend).toBe('screen');
|
||||
mux.destroy();
|
||||
});
|
||||
|
||||
it('should respect CLAUDEMAN_MUX=tmux', () => {
|
||||
process.env.CLAUDEMAN_MUX = 'tmux';
|
||||
tmuxAvailableSpy.mockReturnValue(true);
|
||||
screenAvailableSpy.mockReturnValue(true);
|
||||
|
||||
const mux = createMultiplexer();
|
||||
expect(mux.backend).toBe('tmux');
|
||||
mux.destroy();
|
||||
});
|
||||
|
||||
it('should throw when CLAUDEMAN_MUX=screen but screen unavailable', () => {
|
||||
process.env.CLAUDEMAN_MUX = 'screen';
|
||||
screenAvailableSpy.mockReturnValue(false);
|
||||
|
||||
expect(() => createMultiplexer()).toThrow('Screen requested');
|
||||
});
|
||||
});
|
||||
});
|
||||
+103
-5
@@ -2,18 +2,18 @@
|
||||
* @fileoverview Global test setup for Claudeman tests
|
||||
*
|
||||
* Provides:
|
||||
* - Screen session concurrency limiter (max 10)
|
||||
* - Session concurrency limiter (max 10 tmux/screen sessions)
|
||||
* - Tracked resource cleanup (only kills what tests create)
|
||||
* - Global beforeAll/afterAll hooks
|
||||
*
|
||||
* CRITICAL SAFETY GUARANTEES:
|
||||
* 1. Pre-existing screens (captured at MODULE LOAD) are NEVER killed
|
||||
* 1. Pre-existing screens/tmux sessions (captured at MODULE LOAD) are NEVER killed
|
||||
* 2. Current process screen ($CLAUDEMAN_SCREEN_NAME) is NEVER killed
|
||||
* 3. Only screens explicitly registered via registerTestScreen() can be killed
|
||||
* 4. All screen names must pass validation before being accepted
|
||||
* 3. Only sessions explicitly registered via registerTestScreen()/registerTestTmuxSession() can be killed
|
||||
* 4. All session names must pass validation before being accepted
|
||||
*
|
||||
* This setup ONLY cleans up resources that the test suite itself creates.
|
||||
* It will NEVER kill Claude processes or screens that weren't spawned by tests.
|
||||
* It will NEVER kill Claude processes or sessions that weren't spawned by tests.
|
||||
* This makes it safe to run tests from within a Claudeman-managed session.
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,9 @@ const MAX_CONCURRENT_SCREENS = 10;
|
||||
/** Track active screen sessions created during tests */
|
||||
const activeTestScreens = new Set<string>();
|
||||
|
||||
/** Track active tmux sessions created during tests */
|
||||
const activeTestTmuxSessions = new Set<string>();
|
||||
|
||||
/** Track Claude PIDs spawned by tests (for cleanup) */
|
||||
const activeTestClaudePids = new Set<number>();
|
||||
|
||||
@@ -63,6 +66,25 @@ try {
|
||||
// Ignore errors during capture
|
||||
}
|
||||
|
||||
/**
|
||||
* CRITICAL: Pre-existing tmux sessions captured at MODULE LOAD time.
|
||||
* These sessions existed before any test code ran and must NEVER be killed.
|
||||
*/
|
||||
const preExistingTmuxSessionsAtModuleLoad = new Set<string>();
|
||||
|
||||
// Capture pre-existing tmux sessions IMMEDIATELY when this module loads
|
||||
try {
|
||||
const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null || true", { encoding: 'utf-8', timeout: 5000 });
|
||||
for (const line of output.trim().split('\n')) {
|
||||
const name = line.trim();
|
||||
if (name) {
|
||||
preExistingTmuxSessionsAtModuleLoad.add(name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// tmux may not be running or available
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a screen name matches user-created patterns (w1-*, s1-*)
|
||||
*/
|
||||
@@ -132,6 +154,38 @@ function killTrackedTestScreens(): void {
|
||||
activeTestScreens.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tmux session is protected and must NEVER be killed.
|
||||
*/
|
||||
function isTmuxSessionProtected(sessionName: string): boolean {
|
||||
if (preExistingTmuxSessionsAtModuleLoad.has(sessionName)) {
|
||||
return true;
|
||||
}
|
||||
if (preExistingTmuxSessions.has(sessionName)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill only the tmux sessions that tests have registered via registerTestTmuxSession()
|
||||
*/
|
||||
function killTrackedTestTmuxSessions(): void {
|
||||
for (const sessionName of activeTestTmuxSessions) {
|
||||
if (isTmuxSessionProtected(sessionName)) {
|
||||
console.warn(`[Test Setup] BLOCKED: Refusing to kill protected tmux session: ${sessionName}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
console.log(`[Test Setup] Killing test tmux session: ${sessionName}`);
|
||||
execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`, { encoding: 'utf-8' });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
activeTestTmuxSessions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill only the Claude processes that tests have registered via registerTestClaudePid()
|
||||
*/
|
||||
@@ -210,6 +264,24 @@ export function unregisterTestScreen(screenName: string): void {
|
||||
activeTestScreens.delete(screenName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tmux session for tracking.
|
||||
* SAFETY: Protected sessions will be skipped at cleanup time.
|
||||
*/
|
||||
export function registerTestTmuxSession(sessionName: string): void {
|
||||
if (isTmuxSessionProtected(sessionName)) {
|
||||
console.warn(`[Test Setup] WARNING: Registering protected tmux session ${sessionName} - will be skipped during cleanup`);
|
||||
}
|
||||
activeTestTmuxSessions.add(sessionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a tmux session
|
||||
*/
|
||||
export function unregisterTestTmuxSession(sessionName: string): void {
|
||||
activeTestTmuxSessions.delete(sessionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a Claude PID for tracking (so it gets cleaned up after tests)
|
||||
*/
|
||||
@@ -243,6 +315,9 @@ export function forceCleanupAllTestResources(): void {
|
||||
// Kill all tracked test screens
|
||||
killTrackedTestScreens();
|
||||
|
||||
// Kill all tracked test tmux sessions
|
||||
killTrackedTestTmuxSessions();
|
||||
|
||||
// Kill all tracked Claude processes
|
||||
killTrackedTestClaudeProcesses();
|
||||
|
||||
@@ -258,6 +333,9 @@ export function forceCleanupAllTestResources(): void {
|
||||
/** Screens that existed before tests started (never killed by cleanup) */
|
||||
const preExistingScreens = new Set<string>();
|
||||
|
||||
/** Tmux sessions that existed before tests started (never killed by cleanup) */
|
||||
const preExistingTmuxSessions = new Set<string>();
|
||||
|
||||
/**
|
||||
* List all current claudeman-* screen session names
|
||||
*/
|
||||
@@ -313,6 +391,20 @@ beforeAll(async () => {
|
||||
for (const name of listClaudemanScreens()) {
|
||||
preExistingScreens.add(name);
|
||||
}
|
||||
|
||||
// Record pre-existing tmux sessions so we never kill them
|
||||
try {
|
||||
const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null || true", { encoding: 'utf-8', timeout: 5000 });
|
||||
for (const line of output.trim().split('\n')) {
|
||||
const name = line.trim();
|
||||
if (name) {
|
||||
preExistingTmuxSessions.add(name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// tmux may not be running
|
||||
}
|
||||
|
||||
console.log(`[Test Setup] ${preExistingScreens.size} pre-existing screens preserved`);
|
||||
});
|
||||
|
||||
@@ -341,6 +433,9 @@ afterAll(async () => {
|
||||
if (activeTestScreens.size > 0) {
|
||||
console.warn(`[Test Setup] Warning: ${activeTestScreens.size} test screens weren't properly unregistered`);
|
||||
}
|
||||
if (activeTestTmuxSessions.size > 0) {
|
||||
console.warn(`[Test Setup] Warning: ${activeTestTmuxSessions.size} test tmux sessions weren't properly unregistered`);
|
||||
}
|
||||
if (activeTestClaudePids.size > 0) {
|
||||
console.warn(`[Test Setup] Warning: ${activeTestClaudePids.size} test Claude PIDs weren't properly unregistered`);
|
||||
}
|
||||
@@ -351,10 +446,13 @@ afterAll(async () => {
|
||||
// Export utilities for tests that need them
|
||||
export {
|
||||
killTrackedTestScreens,
|
||||
killTrackedTestTmuxSessions,
|
||||
killTrackedTestClaudeProcesses,
|
||||
MAX_CONCURRENT_SCREENS,
|
||||
isScreenProtected,
|
||||
isTmuxSessionProtected,
|
||||
isTestScreen,
|
||||
isUserScreenPattern,
|
||||
preExistingScreensAtModuleLoad,
|
||||
preExistingTmuxSessionsAtModuleLoad,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for TmuxManager
|
||||
*
|
||||
* Tests validation functions, command construction, and parsing logic
|
||||
* using mocked exec calls. Does NOT create or kill real tmux sessions.
|
||||
*
|
||||
* 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';
|
||||
|
||||
// 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', () => {
|
||||
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('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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user