mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: bump version to 0.1538
This commit is contained in:
@@ -138,32 +138,32 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Spawn', () => {
|
||||
it('should spawn a screen session for the check', async () => {
|
||||
describe('Tmux Spawn', () => {
|
||||
it('should spawn a tmux session for the check', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('terminal output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
// Verify screen was spawned with correct args
|
||||
// Verify tmux was spawned with correct args
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'screen',
|
||||
expect.arrayContaining(['-dmS', expect.stringContaining('claudeman-aicheck-')]),
|
||||
'tmux',
|
||||
expect.arrayContaining(['new-session', '-d', '-s', expect.stringContaining('claudeman-aicheck-')]),
|
||||
expect.objectContaining({ detached: true, stdio: 'ignore' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should kill existing screen with same name before spawning', async () => {
|
||||
it('should kill existing tmux session with same name before spawning', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
// First call should try to kill existing screen
|
||||
// First call should try to kill existing session
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('screen -X -S "claudeman-aicheck-'),
|
||||
expect.stringContaining('tmux kill-session -t "claudeman-aicheck-'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
@@ -213,7 +213,7 @@ describe('AiIdleChecker', () => {
|
||||
expect(result.reasoning).toBe('Cancelled');
|
||||
});
|
||||
|
||||
it('should clean up screen on cancel', async () => {
|
||||
it('should clean up tmux session on cancel', async () => {
|
||||
mockedReadFileSync.mockReturnValue('');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
@@ -221,9 +221,9 @@ describe('AiIdleChecker', () => {
|
||||
checker.cancel();
|
||||
await checkPromise;
|
||||
|
||||
// Should have tried to kill the screen (initial kill + cleanup kill)
|
||||
// Should have tried to kill the tmux session (initial kill + cleanup kill)
|
||||
const killCalls = mockedExecSync.mock.calls.filter(
|
||||
call => typeof call[0] === 'string' && call[0].includes('quit')
|
||||
call => typeof call[0] === 'string' && call[0].includes('kill-session')
|
||||
);
|
||||
expect(killCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
+13
-105
@@ -1,21 +1,18 @@
|
||||
/**
|
||||
* @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.
|
||||
* Tests that createMultiplexer returns a TmuxManager when tmux is available,
|
||||
* and throws when tmux is not available.
|
||||
*
|
||||
* Port: N/A (no server needed)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } 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
|
||||
// Spy on static availability method
|
||||
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 () => {
|
||||
@@ -29,9 +26,6 @@ vi.mock('node:child_process', async () => {
|
||||
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(() => ({
|
||||
@@ -58,107 +52,21 @@ vi.mock('node:fs', async () => {
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
it('should return a TmuxManager when tmux is available', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(true);
|
||||
|
||||
const mux = createMultiplexer();
|
||||
expect(mux.backend).toBe('tmux');
|
||||
mux.destroy();
|
||||
});
|
||||
|
||||
describe('auto-detection', () => {
|
||||
it('should prefer tmux when both are available', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(true);
|
||||
screenAvailableSpy.mockReturnValue(true);
|
||||
it('should throw when tmux is not available', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(false);
|
||||
|
||||
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');
|
||||
});
|
||||
expect(() => createMultiplexer()).toThrow('tmux not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,17 +300,17 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Screen Sessions API Tests ==========
|
||||
// ========== Mux Sessions API Tests ==========
|
||||
|
||||
describe('Screen Sessions API', () => {
|
||||
it('should return screens object with array', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/screens`);
|
||||
describe('Mux Sessions API', () => {
|
||||
it('should return sessions object with array', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/mux-sessions`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.screens).toBeDefined();
|
||||
expect(Array.isArray(data.screens)).toBe(true);
|
||||
expect(typeof data.screenAvailable).toBe('boolean');
|
||||
expect(data.sessions).toBeDefined();
|
||||
expect(Array.isArray(data.sessions)).toBe(true);
|
||||
expect(typeof data.muxAvailable).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class MockSession extends EventEmitter {
|
||||
this.writeBuffer.push(data);
|
||||
}
|
||||
|
||||
async writeViaScreen(data: string): Promise<boolean> {
|
||||
async writeViaMux(data: string): Promise<boolean> {
|
||||
this.writeBuffer.push(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -93,12 +93,12 @@ export class MockSession extends EventEmitter {
|
||||
writeBuffer: string[] = [];
|
||||
terminalBuffer: string = '';
|
||||
|
||||
private _screenName: string | null = null;
|
||||
private _muxName: string | null = null;
|
||||
|
||||
constructor(id: string = 'mock-session-id') {
|
||||
super();
|
||||
this.id = id;
|
||||
this._screenName = `claudeman-test-${id.slice(0, 8)}`;
|
||||
this._muxName = `claudeman-test-${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/** Direct PTY write (used by session.write()) */
|
||||
@@ -106,8 +106,8 @@ export class MockSession extends EventEmitter {
|
||||
this.writeBuffer.push(data);
|
||||
}
|
||||
|
||||
/** Write via screen (used by respawn controller) */
|
||||
async writeViaScreen(data: string): Promise<boolean> {
|
||||
/** Write via mux (used by respawn controller) */
|
||||
async writeViaMux(data: string): Promise<boolean> {
|
||||
this.writeBuffer.push(data);
|
||||
return true;
|
||||
}
|
||||
@@ -234,9 +234,9 @@ export class MockSession extends EventEmitter {
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
/** Get screen name (for screen-based operations) */
|
||||
get screenName(): string | null {
|
||||
return this._screenName;
|
||||
/** Get mux name (for mux-based operations) */
|
||||
get muxName(): string | null {
|
||||
return this._muxName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,616 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Tests for screen-manager module
|
||||
*
|
||||
* Tests the ScreenManager class which manages GNU screen sessions
|
||||
* for persistent Claude sessions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// We test the validation functions and class behavior without spawning real screens
|
||||
// This is a unit test approach that mocks system dependencies
|
||||
|
||||
describe('ScreenManager', () => {
|
||||
// Test the validation functions (extracted for testing)
|
||||
describe('Screen Name Validation', () => {
|
||||
// Valid screen names should match: claudeman-[a-f0-9-]+
|
||||
const validNames = [
|
||||
'claudeman-abc12345',
|
||||
'claudeman-12345678',
|
||||
'claudeman-a1b2c3d4',
|
||||
'claudeman-aabbccdd',
|
||||
'claudeman-a1b2-c3d4',
|
||||
];
|
||||
|
||||
const invalidNames = [
|
||||
'not-claudeman',
|
||||
'claudeman-ABC123', // uppercase not allowed
|
||||
'claudeman-abc123!', // special chars not allowed
|
||||
'claudeman-abc 123', // spaces not allowed
|
||||
'claudeman-', // empty suffix
|
||||
'CLAUDEMAN-abc123', // uppercase prefix
|
||||
'; rm -rf /', // command injection
|
||||
'claudeman-$(whoami)',
|
||||
'claudeman-`id`',
|
||||
];
|
||||
|
||||
it('should accept valid screen names', () => {
|
||||
const pattern = /^claudeman-[a-f0-9-]+$/;
|
||||
for (const name of validNames) {
|
||||
expect(pattern.test(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid screen names', () => {
|
||||
const pattern = /^claudeman-[a-f0-9-]+$/;
|
||||
for (const name of invalidNames) {
|
||||
expect(pattern.test(name)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Validation', () => {
|
||||
const validPaths = [
|
||||
'/home/user/project',
|
||||
'/tmp/test',
|
||||
'/var/lib/claudeman',
|
||||
'~/projects/myapp',
|
||||
'/Users/name/Documents',
|
||||
'/path/with-dashes',
|
||||
'/path/with_underscores',
|
||||
'/path/with.dots',
|
||||
'/path with spaces', // Spaces are allowed
|
||||
];
|
||||
|
||||
const invalidPaths = [
|
||||
'/path;rm -rf /', // semicolon injection
|
||||
'/path&& malicious', // command chaining
|
||||
'/path|cat /etc/passwd', // pipe injection
|
||||
'/path$(whoami)', // command substitution
|
||||
'/path`id`', // backtick substitution
|
||||
'/path(test)', // parentheses
|
||||
'/path{a,b}', // brace expansion
|
||||
"/path'test'", // single quotes
|
||||
'/path"test"', // double quotes
|
||||
'/path<>', // redirection
|
||||
'/path\ntest', // newline
|
||||
];
|
||||
|
||||
it('should validate paths without shell metacharacters', () => {
|
||||
const isValidPath = (path: string): boolean => {
|
||||
if (path.includes(';') || path.includes('&') || path.includes('|') ||
|
||||
path.includes('$') || path.includes('`') || path.includes('(') ||
|
||||
path.includes(')') || path.includes('{') || path.includes('}') ||
|
||||
path.includes('<') || path.includes('>') || path.includes("'") ||
|
||||
path.includes('"') || path.includes('\n') || path.includes('\r')) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-zA-Z0-9_\/\-. ~]+$/.test(path);
|
||||
};
|
||||
|
||||
for (const path of validPaths) {
|
||||
expect(isValidPath(path)).toBe(true);
|
||||
}
|
||||
|
||||
for (const path of invalidPaths) {
|
||||
expect(isValidPath(path)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell Escape Function', () => {
|
||||
const shellEscape = (str: string): string => {
|
||||
return str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$/g, '\\$')
|
||||
.replace(/`/g, '\\`');
|
||||
};
|
||||
|
||||
it('should escape backslashes', () => {
|
||||
expect(shellEscape('path\\file')).toBe('path\\\\file');
|
||||
});
|
||||
|
||||
it('should escape double quotes', () => {
|
||||
expect(shellEscape('say "hello"')).toBe('say \\"hello\\"');
|
||||
});
|
||||
|
||||
it('should escape dollar signs', () => {
|
||||
expect(shellEscape('$HOME/test')).toBe('\\$HOME/test');
|
||||
});
|
||||
|
||||
it('should escape backticks', () => {
|
||||
expect(shellEscape('`whoami`')).toBe('\\`whoami\\`');
|
||||
});
|
||||
|
||||
it('should handle multiple escape characters', () => {
|
||||
expect(shellEscape('$HOME\\file"test"`id`')).toBe('\\$HOME\\\\file\\"test\\"\\`id\\`');
|
||||
});
|
||||
|
||||
it('should not modify safe strings', () => {
|
||||
expect(shellEscape('simple text')).toBe('simple text');
|
||||
expect(shellEscape('/path/to/file')).toBe('/path/to/file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Pattern Matching', () => {
|
||||
const SCREEN_PATTERN = /(\d+)\.(claudeman-([a-f0-9-]+))/g;
|
||||
|
||||
it('should parse screen -ls output correctly', () => {
|
||||
const output = `There are screens on:
|
||||
12345.claudeman-abc12345 (01/15/2024 10:30:00 AM) (Detached)
|
||||
67890.claudeman-def67890 (01/15/2024 11:00:00 AM) (Attached)
|
||||
2 Sockets in /run/screen/S-user.`;
|
||||
|
||||
const matches: Array<{ pid: number; screenName: string; sessionIdFragment: string }> = [];
|
||||
let match;
|
||||
SCREEN_PATTERN.lastIndex = 0;
|
||||
while ((match = SCREEN_PATTERN.exec(output)) !== null) {
|
||||
matches.push({
|
||||
pid: parseInt(match[1], 10),
|
||||
screenName: match[2],
|
||||
sessionIdFragment: match[3],
|
||||
});
|
||||
}
|
||||
|
||||
expect(matches).toHaveLength(2);
|
||||
expect(matches[0].pid).toBe(12345);
|
||||
expect(matches[0].screenName).toBe('claudeman-abc12345');
|
||||
expect(matches[0].sessionIdFragment).toBe('abc12345');
|
||||
expect(matches[1].pid).toBe(67890);
|
||||
expect(matches[1].screenName).toBe('claudeman-def67890');
|
||||
expect(matches[1].sessionIdFragment).toBe('def67890');
|
||||
});
|
||||
|
||||
it('should not match non-claudeman screens', () => {
|
||||
const output = `There are screens on:
|
||||
12345.some-other-screen (01/15/2024 10:30:00 AM) (Detached)
|
||||
67890.another-screen (01/15/2024 11:00:00 AM) (Attached)
|
||||
2 Sockets in /run/screen/S-user.`;
|
||||
|
||||
SCREEN_PATTERN.lastIndex = 0;
|
||||
expect(SCREEN_PATTERN.exec(output)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty screen list', () => {
|
||||
const output = `No Sockets found in /run/screen/S-user.`;
|
||||
|
||||
SCREEN_PATTERN.lastIndex = 0;
|
||||
expect(SCREEN_PATTERN.exec(output)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScreenSession Interface', () => {
|
||||
it('should have all required fields', () => {
|
||||
const session = {
|
||||
sessionId: 'test-session-123',
|
||||
screenName: 'claudeman-abc12345',
|
||||
pid: 12345,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/user/project',
|
||||
mode: 'claude' as const,
|
||||
attached: false,
|
||||
name: 'My Session',
|
||||
};
|
||||
|
||||
expect(session.sessionId).toBeDefined();
|
||||
expect(session.screenName).toBeDefined();
|
||||
expect(session.pid).toBeGreaterThan(0);
|
||||
expect(session.createdAt).toBeGreaterThan(0);
|
||||
expect(session.workingDir).toBeDefined();
|
||||
expect(['claude', 'shell']).toContain(session.mode);
|
||||
expect(typeof session.attached).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should support shell mode', () => {
|
||||
const session = {
|
||||
sessionId: 'shell-session',
|
||||
screenName: 'claudeman-def67890',
|
||||
pid: 67890,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'shell' as const,
|
||||
attached: true,
|
||||
};
|
||||
|
||||
expect(session.mode).toBe('shell');
|
||||
expect(session.attached).toBe(true);
|
||||
});
|
||||
|
||||
it('should support optional name field', () => {
|
||||
const sessionWithName = {
|
||||
sessionId: 'test-1',
|
||||
screenName: 'claudeman-111',
|
||||
pid: 1111,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude' as const,
|
||||
attached: false,
|
||||
name: 'Custom Name',
|
||||
};
|
||||
|
||||
const sessionWithoutName = {
|
||||
sessionId: 'test-2',
|
||||
screenName: 'claudeman-222',
|
||||
pid: 2222,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude' as const,
|
||||
attached: false,
|
||||
};
|
||||
|
||||
expect(sessionWithName.name).toBe('Custom Name');
|
||||
expect(sessionWithoutName.name).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should support respawnConfig field', () => {
|
||||
const session = {
|
||||
sessionId: 'respawn-test',
|
||||
screenName: 'claudeman-resp',
|
||||
pid: 9999,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude' as const,
|
||||
attached: false,
|
||||
respawnConfig: {
|
||||
enabled: true,
|
||||
idleTimeoutMs: 5000,
|
||||
updatePrompt: 'continue',
|
||||
interStepDelayMs: 1000,
|
||||
sendClear: true,
|
||||
sendInit: true,
|
||||
kickstartPrompt: '/init',
|
||||
durationMinutes: 60,
|
||||
},
|
||||
};
|
||||
|
||||
expect(session.respawnConfig?.enabled).toBe(true);
|
||||
expect(session.respawnConfig?.durationMinutes).toBe(60);
|
||||
});
|
||||
|
||||
it('should support ralphEnabled field', () => {
|
||||
const session = {
|
||||
sessionId: 'ralph-test',
|
||||
screenName: 'claudeman-ralph',
|
||||
pid: 8888,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude' as const,
|
||||
attached: false,
|
||||
ralphEnabled: true,
|
||||
};
|
||||
|
||||
expect(session.ralphEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProcessStats Interface', () => {
|
||||
it('should have all required fields', () => {
|
||||
const stats = {
|
||||
memoryMB: 128.5,
|
||||
cpuPercent: 12.3,
|
||||
childCount: 5,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
expect(stats.memoryMB).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.cpuPercent).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.childCount).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.updatedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const stats = {
|
||||
memoryMB: 0,
|
||||
cpuPercent: 0,
|
||||
childCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
expect(stats.memoryMB).toBe(0);
|
||||
expect(stats.cpuPercent).toBe(0);
|
||||
expect(stats.childCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle high values', () => {
|
||||
const stats = {
|
||||
memoryMB: 8192.5,
|
||||
cpuPercent: 100.0,
|
||||
childCount: 50,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
expect(stats.memoryMB).toBe(8192.5);
|
||||
expect(stats.cpuPercent).toBe(100.0);
|
||||
expect(stats.childCount).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Child PID Extraction', () => {
|
||||
// Simulate pgrep output parsing
|
||||
const parseChildPids = (output: string): number[] => {
|
||||
if (!output.trim()) return [];
|
||||
return output.trim().split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p));
|
||||
};
|
||||
|
||||
it('should parse single child PID', () => {
|
||||
expect(parseChildPids('12345\n')).toEqual([12345]);
|
||||
});
|
||||
|
||||
it('should parse multiple child PIDs', () => {
|
||||
expect(parseChildPids('12345\n67890\n11111\n')).toEqual([12345, 67890, 11111]);
|
||||
});
|
||||
|
||||
it('should handle empty output', () => {
|
||||
expect(parseChildPids('')).toEqual([]);
|
||||
expect(parseChildPids(' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore invalid entries', () => {
|
||||
expect(parseChildPids('12345\nnotapid\n67890\n')).toEqual([12345, 67890]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PS Output Parsing', () => {
|
||||
// Simulate ps output parsing
|
||||
const parsePsOutput = (output: string): Map<number, { memoryMB: number; cpuPercent: number }> => {
|
||||
const result = new Map();
|
||||
for (const line of output.trim().split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 3) {
|
||||
const pid = parseInt(parts[0], 10);
|
||||
const rss = parseFloat(parts[1]) || 0;
|
||||
const cpu = parseFloat(parts[2]) || 0;
|
||||
if (!isNaN(pid)) {
|
||||
result.set(pid, {
|
||||
memoryMB: Math.round(rss / 1024 * 10) / 10,
|
||||
cpuPercent: Math.round(cpu * 10) / 10,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
it('should parse ps output with single process', () => {
|
||||
const output = '12345 102400 5.5';
|
||||
const result = parsePsOutput(output);
|
||||
expect(result.get(12345)?.memoryMB).toBe(100);
|
||||
expect(result.get(12345)?.cpuPercent).toBe(5.5);
|
||||
});
|
||||
|
||||
it('should parse ps output with multiple processes', () => {
|
||||
const output = `12345 102400 5.5
|
||||
67890 204800 10.0
|
||||
11111 51200 2.5`;
|
||||
const result = parsePsOutput(output);
|
||||
expect(result.size).toBe(3);
|
||||
expect(result.get(12345)?.memoryMB).toBe(100);
|
||||
expect(result.get(67890)?.memoryMB).toBe(200);
|
||||
expect(result.get(11111)?.cpuPercent).toBe(2.5);
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const output = '12345 0 0';
|
||||
const result = parsePsOutput(output);
|
||||
expect(result.get(12345)?.memoryMB).toBe(0);
|
||||
expect(result.get(12345)?.cpuPercent).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty output', () => {
|
||||
expect(parsePsOutput('').size).toBe(0);
|
||||
expect(parsePsOutput(' ').size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Handling for Screen', () => {
|
||||
// Test the logic of splitting input for screen -X stuff commands
|
||||
const parseInput = (input: string): { textPart: string; hasCarriageReturn: boolean } => {
|
||||
const hasCarriageReturn = input.includes('\r');
|
||||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '');
|
||||
return { textPart, hasCarriageReturn };
|
||||
};
|
||||
|
||||
it('should detect carriage return', () => {
|
||||
const result = parseInput('test\r');
|
||||
expect(result.hasCarriageReturn).toBe(true);
|
||||
expect(result.textPart).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle text without carriage return', () => {
|
||||
const result = parseInput('test');
|
||||
expect(result.hasCarriageReturn).toBe(false);
|
||||
expect(result.textPart).toBe('test');
|
||||
});
|
||||
|
||||
it('should strip newlines', () => {
|
||||
const result = parseInput('test\n');
|
||||
expect(result.textPart).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
const result = parseInput('');
|
||||
expect(result.textPart).toBe('');
|
||||
expect(result.hasCarriageReturn).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle only carriage return', () => {
|
||||
const result = parseInput('\r');
|
||||
expect(result.textPart).toBe('');
|
||||
expect(result.hasCarriageReturn).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle mixed newlines and carriage returns', () => {
|
||||
const result = parseInput('line1\nline2\r');
|
||||
expect(result.textPart).toBe('line1line2');
|
||||
expect(result.hasCarriageReturn).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Session Management Logic', () => {
|
||||
// Test screen session data structure operations
|
||||
let screens: Map<string, { sessionId: string; screenName: string; pid: number }>;
|
||||
|
||||
beforeEach(() => {
|
||||
screens = new Map();
|
||||
});
|
||||
|
||||
it('should add screen sessions', () => {
|
||||
screens.set('session-1', { sessionId: 'session-1', screenName: 'claudeman-111', pid: 111 });
|
||||
screens.set('session-2', { sessionId: 'session-2', screenName: 'claudeman-222', pid: 222 });
|
||||
|
||||
expect(screens.size).toBe(2);
|
||||
expect(screens.get('session-1')?.pid).toBe(111);
|
||||
});
|
||||
|
||||
it('should remove screen sessions', () => {
|
||||
screens.set('session-1', { sessionId: 'session-1', screenName: 'claudeman-111', pid: 111 });
|
||||
screens.delete('session-1');
|
||||
|
||||
expect(screens.size).toBe(0);
|
||||
expect(screens.get('session-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update screen PID', () => {
|
||||
screens.set('session-1', { sessionId: 'session-1', screenName: 'claudeman-111', pid: 111 });
|
||||
const screen = screens.get('session-1')!;
|
||||
screen.pid = 999;
|
||||
|
||||
expect(screens.get('session-1')?.pid).toBe(999);
|
||||
});
|
||||
|
||||
it('should iterate over screens', () => {
|
||||
screens.set('session-1', { sessionId: 'session-1', screenName: 'claudeman-111', pid: 111 });
|
||||
screens.set('session-2', { sessionId: 'session-2', screenName: 'claudeman-222', pid: 222 });
|
||||
|
||||
const sessionIds: string[] = [];
|
||||
for (const [id] of screens) {
|
||||
sessionIds.push(id);
|
||||
}
|
||||
|
||||
expect(sessionIds).toContain('session-1');
|
||||
expect(sessionIds).toContain('session-2');
|
||||
});
|
||||
|
||||
it('should check if screen exists', () => {
|
||||
screens.set('session-1', { sessionId: 'session-1', screenName: 'claudeman-111', pid: 111 });
|
||||
|
||||
expect(screens.has('session-1')).toBe(true);
|
||||
expect(screens.has('session-2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reconciliation Logic', () => {
|
||||
it('should categorize screens as alive or dead', () => {
|
||||
const knownScreens = new Map([
|
||||
['session-1', { screenName: 'claudeman-111', pid: 111 }],
|
||||
['session-2', { screenName: 'claudeman-222', pid: 222 }],
|
||||
['session-3', { screenName: 'claudeman-333', pid: 333 }],
|
||||
]);
|
||||
|
||||
// Simulate screen -ls showing only some screens are alive
|
||||
const aliveScreenNames = new Set(['claudeman-111', 'claudeman-333']);
|
||||
|
||||
const alive: string[] = [];
|
||||
const dead: string[] = [];
|
||||
|
||||
for (const [sessionId, screen] of knownScreens) {
|
||||
if (aliveScreenNames.has(screen.screenName)) {
|
||||
alive.push(sessionId);
|
||||
} else {
|
||||
dead.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
expect(alive).toEqual(['session-1', 'session-3']);
|
||||
expect(dead).toEqual(['session-2']);
|
||||
});
|
||||
|
||||
it('should discover unknown screens', () => {
|
||||
const knownScreenNames = new Set(['claudeman-111']);
|
||||
const discoveredFromScreenLs = [
|
||||
{ pid: 111, screenName: 'claudeman-111', sessionIdFragment: '111' },
|
||||
{ pid: 222, screenName: 'claudeman-222', sessionIdFragment: '222' },
|
||||
{ pid: 333, screenName: 'claudeman-333', sessionIdFragment: '333' },
|
||||
];
|
||||
|
||||
const discovered: Array<{ screenName: string; sessionIdFragment: string }> = [];
|
||||
|
||||
for (const screen of discoveredFromScreenLs) {
|
||||
if (!knownScreenNames.has(screen.screenName)) {
|
||||
discovered.push({
|
||||
screenName: screen.screenName,
|
||||
sessionIdFragment: screen.sessionIdFragment,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(discovered).toHaveLength(2);
|
||||
expect(discovered[0].screenName).toBe('claudeman-222');
|
||||
expect(discovered[1].screenName).toBe('claudeman-333');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environment Variables', () => {
|
||||
it('should format environment variables correctly for claude mode', () => {
|
||||
const sessionId = 'test-session-123';
|
||||
const screenName = 'claudeman-abc12345';
|
||||
|
||||
const envVars = `CLAUDEMAN_SCREEN=1 CLAUDEMAN_SESSION_ID=${sessionId} CLAUDEMAN_SCREEN_NAME=${screenName}`;
|
||||
|
||||
expect(envVars).toContain('CLAUDEMAN_SCREEN=1');
|
||||
expect(envVars).toContain('CLAUDEMAN_SESSION_ID=test-session-123');
|
||||
expect(envVars).toContain('CLAUDEMAN_SCREEN_NAME=claudeman-abc12345');
|
||||
});
|
||||
|
||||
it('should use claude command for claude mode', () => {
|
||||
const mode = 'claude' as const;
|
||||
const cmd = mode === 'claude'
|
||||
? 'claude --dangerously-skip-permissions'
|
||||
: '$SHELL';
|
||||
|
||||
expect(cmd).toBe('claude --dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('should use $SHELL for shell mode', () => {
|
||||
const mode = 'shell' as const;
|
||||
const cmd = mode === 'claude'
|
||||
? 'claude --dangerously-skip-permissions'
|
||||
: '$SHELL';
|
||||
|
||||
expect(cmd).toBe('$SHELL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Name Generation', () => {
|
||||
it('should generate screen name from session ID', () => {
|
||||
const sessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
const screenName = `claudeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
expect(screenName).toBe('claudeman-a1b2c3d4');
|
||||
});
|
||||
|
||||
it('should handle short session IDs', () => {
|
||||
const sessionId = 'abc';
|
||||
const screenName = `claudeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
expect(screenName).toBe('claudeman-abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Restored Session Naming', () => {
|
||||
it('should create restored session ID', () => {
|
||||
const sessionIdFragment = 'abc12345';
|
||||
const restoredSessionId = `restored-${sessionIdFragment}`;
|
||||
|
||||
expect(restoredSessionId).toBe('restored-abc12345');
|
||||
});
|
||||
|
||||
it('should create restored session name', () => {
|
||||
const screenName = 'claudeman-abc12345';
|
||||
const name = `Restored: ${screenName}`;
|
||||
|
||||
expect(name).toBe('Restored: claudeman-abc12345');
|
||||
});
|
||||
});
|
||||
});
|
||||
+17
-17
@@ -278,37 +278,37 @@ describe('Session State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Session Environment', () => {
|
||||
interface ScreenEnv {
|
||||
CLAUDEMAN_SCREEN: string;
|
||||
describe('Mux Session Environment', () => {
|
||||
interface MuxEnv {
|
||||
CLAUDEMAN_MUX: string;
|
||||
CLAUDEMAN_SESSION_ID: string;
|
||||
CLAUDEMAN_SCREEN_NAME: string;
|
||||
CLAUDEMAN_MUX_NAME: string;
|
||||
}
|
||||
|
||||
const createScreenEnv = (sessionId: string, screenName: string): ScreenEnv => ({
|
||||
CLAUDEMAN_SCREEN: '1',
|
||||
const createMuxEnv = (sessionId: string, muxName: string): MuxEnv => ({
|
||||
CLAUDEMAN_MUX: '1',
|
||||
CLAUDEMAN_SESSION_ID: sessionId,
|
||||
CLAUDEMAN_SCREEN_NAME: screenName,
|
||||
CLAUDEMAN_MUX_NAME: muxName,
|
||||
});
|
||||
|
||||
const isClaudemanSession = (env: Record<string, string | undefined>): boolean => {
|
||||
return env.CLAUDEMAN_SCREEN === '1';
|
||||
return env.CLAUDEMAN_MUX === '1';
|
||||
};
|
||||
|
||||
it('should create valid screen environment', () => {
|
||||
const env = createScreenEnv('session-123', 'claudeman-test');
|
||||
expect(env.CLAUDEMAN_SCREEN).toBe('1');
|
||||
it('should create valid mux environment', () => {
|
||||
const env = createMuxEnv('session-123', 'claudeman-test');
|
||||
expect(env.CLAUDEMAN_MUX).toBe('1');
|
||||
expect(env.CLAUDEMAN_SESSION_ID).toBe('session-123');
|
||||
expect(env.CLAUDEMAN_SCREEN_NAME).toBe('claudeman-test');
|
||||
expect(env.CLAUDEMAN_MUX_NAME).toBe('claudeman-test');
|
||||
});
|
||||
|
||||
it('should detect claudeman session', () => {
|
||||
const env = createScreenEnv('session-123', 'claudeman-test');
|
||||
const env = createMuxEnv('session-123', 'claudeman-test');
|
||||
expect(isClaudemanSession(env)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect non-claudeman session', () => {
|
||||
const env = { CLAUDEMAN_SCREEN: '0' };
|
||||
const env = { CLAUDEMAN_MUX: '0' };
|
||||
expect(isClaudemanSession(env)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -382,7 +382,7 @@ describe('Session State Management', () => {
|
||||
interface CleanupState {
|
||||
buffersCleared: boolean;
|
||||
timersCleared: boolean;
|
||||
screenKilled: boolean;
|
||||
muxKilled: boolean;
|
||||
respawnStopped: boolean;
|
||||
trackerReset: boolean;
|
||||
}
|
||||
@@ -390,7 +390,7 @@ describe('Session State Management', () => {
|
||||
const createCleanupState = (): CleanupState => ({
|
||||
buffersCleared: false,
|
||||
timersCleared: false,
|
||||
screenKilled: false,
|
||||
muxKilled: false,
|
||||
respawnStopped: false,
|
||||
trackerReset: false,
|
||||
});
|
||||
@@ -415,7 +415,7 @@ describe('Session State Management', () => {
|
||||
const state: CleanupState = {
|
||||
buffersCleared: true,
|
||||
timersCleared: true,
|
||||
screenKilled: true,
|
||||
muxKilled: true,
|
||||
respawnStopped: true,
|
||||
trackerReset: true,
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Global test setup for Claudeman tests
|
||||
*
|
||||
* SAFETY: TmuxManager and ScreenManager have built-in test mode detection
|
||||
* SAFETY: TmuxManager has built-in test mode detection
|
||||
* (via process.env.VITEST) that makes ALL shell commands no-ops.
|
||||
* This means tests CANNOT kill, create, or interact with real tmux/screen
|
||||
* This means tests CANNOT kill, create, or interact with real tmux
|
||||
* sessions regardless of what the test code does.
|
||||
*
|
||||
* This setup file only handles mock/timer cleanup between tests.
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('generateClaudeMd', () => {
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
expect(result).toContain('## Claudeman Environment');
|
||||
expect(result).toContain('CLAUDEMAN_SCREEN=1');
|
||||
expect(result).toContain('CLAUDEMAN_MUX=1');
|
||||
});
|
||||
|
||||
it('should include work principles', () => {
|
||||
|
||||
+10
-10
@@ -194,9 +194,9 @@ describe('TmuxManager (unit)', () => {
|
||||
expect(manager.getSession('kill-test')).toBeUndefined();
|
||||
});
|
||||
|
||||
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';
|
||||
it('should allow kill when session does NOT match CLAUDEMAN_MUX_NAME', async () => {
|
||||
const originalEnv = process.env.CLAUDEMAN_MUX_NAME;
|
||||
process.env.CLAUDEMAN_MUX_NAME = 'claudeman-0ther1111';
|
||||
|
||||
try {
|
||||
manager.registerSession({
|
||||
@@ -219,16 +219,16 @@ describe('TmuxManager (unit)', () => {
|
||||
expect(manager.getSession('other-kill-test')).toBeUndefined();
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
delete process.env.CLAUDEMAN_MUX_NAME;
|
||||
} else {
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
|
||||
process.env.CLAUDEMAN_MUX_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;
|
||||
it('should allow kill when CLAUDEMAN_MUX_NAME is not set', async () => {
|
||||
const originalEnv = process.env.CLAUDEMAN_MUX_NAME;
|
||||
delete process.env.CLAUDEMAN_MUX_NAME;
|
||||
|
||||
try {
|
||||
manager.registerSession({
|
||||
@@ -247,9 +247,9 @@ describe('TmuxManager (unit)', () => {
|
||||
expect(result).toBe(true);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
delete process.env.CLAUDEMAN_MUX_NAME;
|
||||
} else {
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
|
||||
process.env.CLAUDEMAN_MUX_NAME = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user