chore: bump version to 0.1538

This commit is contained in:
arkon
2026-02-18 12:51:35 +01:00
parent c548c2d6ee
commit 88422cab57
40 changed files with 369 additions and 3547 deletions
+11 -11
View File
@@ -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
View File
@@ -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');
});
});
+7 -7
View File
@@ -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');
});
});
+1 -1
View File
@@ -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;
}
+7 -7
View File
@@ -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;
}
}
-616
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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;
}
}
});