mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix: bulletproof test safety — IS_TEST_MODE guards prevent tests from killing real tmux sessions
Added IS_TEST_MODE (process.env.VITEST) guards to every method in TmuxManager and ScreenManager that touches real tmux/screen sessions. Tests can never create, kill, discover, or send input to real sessions. Removed broken E2E test suite entirely. Rewrote test/setup.ts from 459 lines to minimal cleanup. Rewrote tmux-related tests to verify test-mode safety behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+43
-282
@@ -1,276 +1,27 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
/**
|
||||
* PTY/Interactive Session Tests
|
||||
* @fileoverview Unit tests for Session state management
|
||||
*
|
||||
* These tests verify that:
|
||||
* 1. Claude CLI is spawned with correct flags (--dangerously-skip-permissions)
|
||||
* 2. Interactive sessions open a TTY
|
||||
* 3. Input can be sent to the terminal
|
||||
* 4. Output is received from the terminal
|
||||
* 5. The session properly detects idle/working states
|
||||
* Tests Session object lifecycle, state transitions, and buffer management.
|
||||
* SAFETY: No real Claude CLI processes are spawned. Tests verify in-memory
|
||||
* state transitions and API contracts only.
|
||||
*
|
||||
* Port: N/A (no server or real processes needed)
|
||||
*/
|
||||
|
||||
describe('PTY Interactive Session', () => {
|
||||
// Use /tmp which always exists instead of a timestamped directory
|
||||
const testDir = '/tmp';
|
||||
|
||||
describe('Session Spawning', () => {
|
||||
it('should spawn interactive session with correct flags', async () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
// Track what's being spawned
|
||||
let terminalOutput = '';
|
||||
session.on('terminal', (data: string) => {
|
||||
terminalOutput += data;
|
||||
});
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Session should have a PID (process is running)
|
||||
expect(session.pid).toBeGreaterThan(0);
|
||||
expect(session.status).toBe('busy');
|
||||
|
||||
// Wait a bit for Claude to initialize
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Terminal output may or may not have content depending on Claude's startup speed
|
||||
// Just verify the session was started successfully
|
||||
expect(session.isRunning()).toBe(true);
|
||||
|
||||
// Clean up
|
||||
await session.stop();
|
||||
});
|
||||
|
||||
it('should emit terminal events with raw PTY data', async () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
const terminalEvents: string[] = [];
|
||||
session.on('terminal', (data: string) => {
|
||||
terminalEvents.push(data);
|
||||
});
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Wait for some output
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Session should be running regardless of output
|
||||
expect(session.isRunning()).toBe(true);
|
||||
|
||||
// Terminal events may or may not have been received depending on timing
|
||||
// The important thing is the session is working
|
||||
expect(session.pid).toBeGreaterThan(0);
|
||||
|
||||
await session.stop();
|
||||
});
|
||||
|
||||
it('should accept input via write() method', async () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
let terminalOutput = '';
|
||||
session.on('terminal', (data: string) => {
|
||||
terminalOutput += data;
|
||||
});
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Wait for Claude to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Send some input (just a newline to test input works)
|
||||
session.write('\n');
|
||||
|
||||
// Wait for response
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Terminal should have accumulated output
|
||||
expect(terminalOutput.length).toBeGreaterThan(0);
|
||||
|
||||
await session.stop();
|
||||
});
|
||||
|
||||
it('should handle terminal resize', async () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Resize should not throw
|
||||
expect(() => {
|
||||
session.resize(80, 24);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => {
|
||||
session.resize(200, 50);
|
||||
}).not.toThrow();
|
||||
|
||||
await session.stop();
|
||||
});
|
||||
|
||||
it('should emit exit event when stopped', async () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
let exitEmitted = false;
|
||||
session.on('exit', () => {
|
||||
exitEmitted = true;
|
||||
});
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Wait for process to start
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
await session.stop();
|
||||
|
||||
// Wait for exit event
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Exit event should have been emitted or status should reflect termination
|
||||
// Status can be 'stopped' (if stop() completed) or 'idle' (if PTY exited first)
|
||||
expect(['stopped', 'idle']).toContain(session.status);
|
||||
});
|
||||
|
||||
it('should track terminal buffer', async () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Wait for some output
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Terminal buffer may or may not have content depending on Claude startup timing
|
||||
// The important thing is that the property exists and session is running
|
||||
expect(session.terminalBuffer).toBeDefined();
|
||||
expect(session.isRunning()).toBe(true);
|
||||
|
||||
await session.stop();
|
||||
});
|
||||
|
||||
it('should not allow starting interactive session twice', async () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Second call should throw
|
||||
await expect(session.startInteractive()).rejects.toThrow('already has a running process');
|
||||
|
||||
await session.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Idle Detection', () => {
|
||||
it('should detect idle state after prompt appears', async () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
let idleEmitted = false;
|
||||
session.on('idle', () => {
|
||||
idleEmitted = true;
|
||||
});
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Wait for Claude to initialize and become idle (shows prompt)
|
||||
// This might take a while depending on Claude startup
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// Idle should be emitted once Claude shows its prompt
|
||||
// Note: This depends on Claude actually starting up successfully
|
||||
// which requires the CLI to be installed
|
||||
|
||||
await session.stop();
|
||||
|
||||
// We just verify the session handled everything without errors
|
||||
expect(session.status).toBe('stopped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Working Detection', () => {
|
||||
it('should detect working state when Claude is processing', async () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
let workingEmitted = false;
|
||||
session.on('working', () => {
|
||||
workingEmitted = true;
|
||||
});
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Wait for Claude to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// Send a simple command to trigger work
|
||||
session.write('hello\n');
|
||||
|
||||
// Wait for Claude to process
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
await session.stop();
|
||||
|
||||
// We verify the session handled the input without errors
|
||||
expect(session.status).toBe('stopped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Run Prompt Mode', () => {
|
||||
it('should spawn with -p flag for one-shot prompts', async () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
let terminalOutput = '';
|
||||
session.on('terminal', (data: string) => {
|
||||
terminalOutput += data;
|
||||
});
|
||||
|
||||
// Use a very simple prompt - this will actually call Claude
|
||||
// We don't wait for completion since that costs API credits
|
||||
const runPromise = session.runPrompt('echo test').catch(() => {
|
||||
// Ignore rejection from stop() - we're intentionally stopping early
|
||||
});
|
||||
|
||||
// Give it a moment to spawn
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Session should be busy
|
||||
expect(session.status).toBe('busy');
|
||||
expect(session.pid).toBeGreaterThan(0);
|
||||
|
||||
// Stop the session to avoid waiting for API completion
|
||||
await session.stop();
|
||||
|
||||
// Wait for the promise to resolve/reject
|
||||
await runPromise;
|
||||
});
|
||||
});
|
||||
});
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
describe('Session State Management', () => {
|
||||
const testDir = '/tmp';
|
||||
|
||||
it('should transition through states correctly', async () => {
|
||||
it('should start in idle state', () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
// Initial state
|
||||
expect(session.status).toBe('idle');
|
||||
expect(session.isIdle()).toBe(true);
|
||||
expect(session.isBusy()).toBe(false);
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// After starting
|
||||
expect(session.status).toBe('busy');
|
||||
expect(session.isIdle()).toBe(false);
|
||||
expect(session.isBusy()).toBe(true);
|
||||
|
||||
await session.stop();
|
||||
|
||||
// After stopping
|
||||
expect(session.status).toBe('stopped');
|
||||
});
|
||||
|
||||
it('should provide state snapshots', async () => {
|
||||
it('should provide state snapshots', () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
|
||||
const state = session.toState();
|
||||
@@ -288,32 +39,42 @@ describe('Session State Management', () => {
|
||||
expect(detailedState).toHaveProperty('messageCount');
|
||||
});
|
||||
|
||||
it('should clear buffers', async () => {
|
||||
// Use shell mode for reliable output timing (Claude CLI startup is unpredictable)
|
||||
const session = new Session({ workingDir: testDir, mode: 'shell' });
|
||||
|
||||
await session.startShell();
|
||||
|
||||
// Send a command to generate output
|
||||
session.write('echo "test output"\r');
|
||||
|
||||
// Wait for output with polling
|
||||
let attempts = 0;
|
||||
while (session.terminalBuffer.length === 0 && attempts < 20) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Should have some buffer content from shell
|
||||
expect(session.terminalBuffer.length).toBeGreaterThan(0);
|
||||
|
||||
// Clear buffers
|
||||
session.clearBuffers();
|
||||
|
||||
it('should have empty buffers initially', () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
expect(session.terminalBuffer).toBe('');
|
||||
expect(session.textOutput).toBe('');
|
||||
expect(session.errorBuffer).toBe('');
|
||||
});
|
||||
|
||||
await session.stop();
|
||||
it('should clear buffers', () => {
|
||||
const session = new Session({ workingDir: testDir });
|
||||
session.clearBuffers();
|
||||
expect(session.terminalBuffer).toBe('');
|
||||
expect(session.textOutput).toBe('');
|
||||
expect(session.errorBuffer).toBe('');
|
||||
});
|
||||
|
||||
it('should track working directory', () => {
|
||||
const session = new Session({ workingDir: '/home/test/project' });
|
||||
expect(session.workingDir).toBe('/home/test/project');
|
||||
});
|
||||
|
||||
it('should generate unique session IDs', () => {
|
||||
const session1 = new Session({ workingDir: testDir });
|
||||
const session2 = new Session({ workingDir: testDir });
|
||||
expect(session1.id).not.toBe(session2.id);
|
||||
});
|
||||
|
||||
it('should accept custom session names', () => {
|
||||
const session = new Session({ workingDir: testDir, name: 'My Session' });
|
||||
expect(session.name).toBe('My Session');
|
||||
});
|
||||
|
||||
it('should use specified mode', () => {
|
||||
const claudeSession = new Session({ workingDir: testDir, mode: 'claude' });
|
||||
expect(claudeSession.mode).toBe('claude');
|
||||
|
||||
const shellSession = new Session({ workingDir: testDir, mode: 'shell' });
|
||||
expect(shellSession.mode).toBe('shell');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user