Skip to main content
Glama
irskep

persistproc

by irskep
test_process_manager_unit.py20.9 kB
"""Unit tests for ProcessManager using fakes to avoid real processes and threading.""" from pathlib import Path from unittest.mock import Mock, patch import pytest from persistproc.process_manager import ProcessManager, get_label from tests.fakes import ( FakeSubprocessPopen, create_fake_proc_entry, create_fake_registry, ) @pytest.fixture def temp_dir(tmp_path): """Provide a temporary directory for tests.""" return tmp_path @pytest.fixture def fake_registry(temp_dir): """Create a fake registry with fake dependencies.""" return create_fake_registry(temp_dir) @pytest.fixture def process_manager(fake_registry, temp_dir): """Create a ProcessManager with fake dependencies and no monitoring.""" # Disable monitoring to avoid real threading return ProcessManager( temp_dir / "server.log", monitor=False, registry=fake_registry, data_dir=temp_dir, ) class TestGetLabel: """Test the get_label utility function.""" def test_explicit_label(self, tmp_path): """Test that explicit label is used when provided.""" result = get_label("my-custom-label", "python script.py", str(tmp_path)) assert result == "my-custom-label" def test_generated_label(self): """Test that label is generated from command and directory.""" result = get_label(None, "python script.py", "/home/user") assert result == "python script.py in /home/user" def test_empty_explicit_label(self, tmp_path): """Test that empty string is treated as no explicit label.""" result = get_label("", "echo hello", str(tmp_path)) assert result == f"echo hello in {tmp_path}" class TestProcessManagerInit: """Test ProcessManager initialization.""" def test_init_with_monitoring_disabled(self, fake_registry, temp_dir): """Test initialization with monitoring disabled.""" pm = ProcessManager( temp_dir / "server.log", monitor=False, registry=fake_registry, data_dir=temp_dir, ) assert pm.data_dir == temp_dir assert pm.monitor is False assert pm._monitor_thread is None def test_init_with_monitoring_enabled(self, fake_registry, temp_dir): """Test initialization with monitoring enabled (but we avoid real threading).""" with patch("threading.Thread") as mock_thread: mock_thread_instance = Mock() mock_thread.return_value = mock_thread_instance ProcessManager( temp_dir / "server.log", monitor=True, registry=fake_registry, data_dir=temp_dir, ) # Should create and start thread mock_thread.assert_called_once() mock_thread_instance.start.assert_called_once() class TestProcessManagerStart: """Test ProcessManager.start() method.""" @patch("persistproc.process_manager.subprocess.Popen") def test_start_success(self, mock_popen, process_manager, tmp_path): """Test successful process start.""" # Setup fake process fake_proc = FakeSubprocessPopen(pid=1234) mock_popen.return_value = fake_proc result = process_manager.start( command="echo hello", working_directory=tmp_path, environment={"TEST": "value"}, label="test-echo", ) # Verify result assert result.pid == 1234 assert result.label == "test-echo" assert result.error is None assert "1234.echo_hello.stdout" in str(result.log_stdout) # Verify process was stored stored_proc = process_manager._storage.get_process_snapshot(1234) assert stored_proc is not None assert stored_proc.command == ["echo", "hello"] assert stored_proc.status == "running" assert stored_proc.label == "test-echo" @patch("persistproc.process_manager.subprocess.Popen") def test_start_with_generated_label(self, mock_popen, process_manager, tmp_path): """Test process start with auto-generated label.""" fake_proc = FakeSubprocessPopen(pid=5678) mock_popen.return_value = fake_proc result = process_manager.start( command="python script.py", working_directory=tmp_path, label=None, # No explicit label ) assert result.label == f"python script.py in {tmp_path}" stored_proc = process_manager._storage.get_process_snapshot(5678) assert stored_proc.label == f"python script.py in {tmp_path}" def test_start_duplicate_label_error(self, process_manager, tmp_path): """Test that starting process with duplicate running label fails.""" # Add a running process with same label existing_proc = create_fake_proc_entry( pid=1111, status="running", label="test-label" ) process_manager._storage.add_process(existing_proc) # Try to start another with same label result = process_manager.start( command="echo test", working_directory=tmp_path, label="test-label" ) assert result.error is not None assert "already running" in result.error assert "PID 1111" in result.error def test_start_invalid_directory(self, process_manager): """Test starting process with non-existent directory.""" result = process_manager.start( command="echo test", working_directory=Path("/nonexistent/directory") ) assert result.error is not None assert "does not exist" in result.error @patch("persistproc.process_manager.subprocess.Popen") def test_start_file_not_found(self, mock_popen, process_manager, tmp_path): """Test starting non-existent command.""" mock_popen.side_effect = FileNotFoundError("nonexistent-command") result = process_manager.start( command="nonexistent-command", working_directory=tmp_path ) assert result.error is not None assert "Command not found" in result.error @patch("persistproc.process_manager.subprocess.Popen") def test_start_permission_error(self, mock_popen, process_manager, tmp_path): """Test starting command with permission error.""" mock_popen.side_effect = PermissionError("permission denied") result = process_manager.start( command="restricted-command", working_directory=tmp_path ) assert result.error is not None assert "Permission denied" in result.error class TestProcessManagerList: """Test ProcessManager.list() method.""" def test_list_empty(self, process_manager): """Test listing when no processes exist.""" result = process_manager.list() assert result.processes == [] def test_list_with_processes(self, process_manager): """Test listing with multiple processes.""" # Add some test processes proc1 = create_fake_proc_entry(pid=1234, label="proc1", status="running") proc2 = create_fake_proc_entry(pid=5678, label="proc2", status="exited") process_manager._storage.add_process(proc1) process_manager._storage.add_process(proc2) result = process_manager.list() assert len(result.processes) == 2 # Verify process info conversion proc_by_pid = {p.pid: p for p in result.processes} assert proc_by_pid[1234].label == "proc1" assert proc_by_pid[1234].status == "running" assert proc_by_pid[5678].label == "proc2" assert proc_by_pid[5678].status == "exited" def test_list_with_pid_zero_returns_server_info(self, process_manager): """Test that list with pid=0 returns server ProcessInfo.""" with patch("os.getpid", return_value=9999): result = process_manager.list(pid=0) # Should return only the server process assert len(result.processes) == 1 server_process = result.processes[0] # Verify server process data assert server_process.pid == 9999 assert server_process.label == "persistproc-server" assert server_process.status == "running" assert server_process.command == ["persistproc", "serve"] class TestProcessManagerListWithStatus: """Test ProcessManager.list() method returning status information.""" def test_list_with_status_by_pid(self, process_manager): """Test getting status by PID via list method.""" proc = create_fake_proc_entry(pid=1234, command=["python", "script.py"]) process_manager._storage.add_process(proc) result = process_manager.list(pid=1234) assert len(result.processes) == 1 process_info = result.processes[0] assert process_info.pid == 1234 assert process_info.command == ["python", "script.py"] assert process_info.status == "running" def test_list_with_status_pid_not_found(self, process_manager): """Test list method for non-existent PID.""" result = process_manager.list(pid=9999) assert len(result.processes) == 0 def test_list_with_status_by_label(self, process_manager): """Test getting status by label via list method.""" proc = create_fake_proc_entry(pid=1234, label="my-app") process_manager._storage.add_process(proc) result = process_manager.list(command_or_label="my-app") assert len(result.processes) == 1 process_info = result.processes[0] assert process_info.pid == 1234 assert process_info.label == "my-app" class TestProcessManagerStop: """Test ProcessManager.stop() method.""" def test_stop_requires_identifier(self, process_manager): """Test that stop requires some identifier.""" result = process_manager.stop() assert result.error is not None assert "must be provided" in result.error def test_stop_by_pid_success(self, process_manager): """Test successful stop by PID.""" # Create a fake running process fake_proc = FakeSubprocessPopen(pid=1234, returncode=None) proc_entry = create_fake_proc_entry(pid=1234, status="running", proc=fake_proc) process_manager._storage.add_process(proc_entry) with ( patch("persistproc.process_manager.os.killpg"), patch("persistproc.process_manager.os.getpgid") as mock_getpgid, ): mock_getpgid.return_value = 1234 # Simulate process exiting after SIGTERM fake_proc.returncode = 0 result = process_manager.stop(pid=1234) assert result.error is None assert result.exit_code == 0 # Verify process was updated to terminated status updated_proc = process_manager._storage.get_process_snapshot(1234) assert updated_proc.status == "terminated" def test_stop_process_not_running(self, process_manager): """Test stopping process that's not running.""" proc_entry = create_fake_proc_entry(pid=1234, status="exited") process_manager._storage.add_process(proc_entry) result = process_manager.stop(pid=1234) assert result.error is not None assert "not running" in result.error class TestProcessManagerRestart: """Test ProcessManager.restart() method.""" def test_restart_process_not_found(self, process_manager): """Test restarting non-existent process.""" result = process_manager.restart(pid=9999) assert result.error is not None assert "not found" in result.error.lower() @patch("persistproc.process_manager.subprocess.Popen") def test_restart_success(self, mock_popen, process_manager, tmp_path): """Test successful restart.""" # Setup original process original_proc = FakeSubprocessPopen(pid=1234, returncode=None) proc_entry = create_fake_proc_entry( pid=1234, command=["python", "script.py"], working_directory=str(tmp_path), status="running", proc=original_proc, ) process_manager._storage.add_process(proc_entry) # Setup new process after restart new_proc = FakeSubprocessPopen(pid=5678) mock_popen.return_value = new_proc with ( patch("persistproc.process_manager.os.killpg"), patch("persistproc.process_manager.os.getpgid"), ): # Simulate original process exiting original_proc.returncode = 0 result = process_manager.restart(pid=1234) assert result.error is None assert result.pid == 5678 # New PID class TestProcessManagerGetOutput: """Test ProcessManager.get_output() method.""" def test_get_output_process_not_found(self, process_manager): """Test getting output for non-existent process.""" result = process_manager.get_output(pid=9999) assert result.error is not None assert "not found" in result.error.lower() def test_get_output_log_file_missing(self, process_manager, temp_dir): """Test getting output when log file doesn't exist.""" proc_entry = create_fake_proc_entry(pid=1234, log_prefix="1234.test") process_manager._storage.add_process(proc_entry) result = process_manager.get_output(pid=1234, stream="stdout") # Should return empty output for missing file assert result.error is None assert result.output == [] def test_get_output_with_log_file(self, process_manager, temp_dir): """Test getting output when log file exists.""" proc_entry = create_fake_proc_entry(pid=1234, log_prefix="1234.test") process_manager._storage.add_process(proc_entry) # Create a fake log file log_file = temp_dir / "process_logs" / "1234.test.stdout" log_file.parent.mkdir(parents=True, exist_ok=True) log_file.write_text( "2024-01-01T10:00:00.000Z Hello world\n2024-01-01T10:00:01.000Z Second line\n" ) result = process_manager.get_output(pid=1234, stream="stdout") # Debug output if test fails if result.error: print(f"Error: {result.error}") print(f"Log file exists: {log_file.exists()}") log_contents = log_file.read_text() print(f"Log file contents: {log_contents!r}") lines = log_contents.splitlines(keepends=True) print(f"Lines from file: {lines}") for i, line in enumerate(lines): timestamp = line.split(" ", 1)[0] if " " in line else line.strip() print(f"Line {i}: timestamp='{timestamp}', full='{line.strip()}'") print(f"Result output: {result.output}") assert result.error is None assert len(result.output) == 2 assert "Hello world" in result.output[0] assert "Second line" in result.output[1] class TestProcessManagerListWithLogPaths: """Test ProcessManager.list() method returning log paths.""" def test_list_with_log_paths_success(self, process_manager): """Test getting log paths via list method for existing process.""" proc_entry = create_fake_proc_entry(pid=1234, log_prefix="1234.test") process_manager._storage.add_process(proc_entry) result = process_manager.list(pid=1234) assert len(result.processes) == 1 process_info = result.processes[0] assert process_info.pid == 1234 assert "1234.test.stdout" in process_info.log_stdout assert "1234.test.stderr" in process_info.log_stderr assert "1234.test.combined" in process_info.log_combined def test_list_with_log_paths_not_found(self, process_manager): """Test list method for non-existent process.""" result = process_manager.list(pid=9999) assert len(result.processes) == 0 class TestProcessManagerShutdownMethod: """Test ProcessManager.shutdown() method.""" def test_shutdown_no_processes(self, process_manager): """Test shutting down persistproc with no managed processes.""" with ( patch("os.getpid", return_value=12345), patch("threading.Thread") as mock_thread, ): result = process_manager.shutdown() assert result.pid == 12345 # Should start a thread to kill the server mock_thread.assert_called_once() def test_shutdown_with_processes(self, process_manager): """Test shutting down persistproc with managed processes.""" # Add some running processes proc1 = create_fake_proc_entry(pid=1234, status="running") proc2 = create_fake_proc_entry(pid=5678, status="exited") process_manager._storage.add_process(proc1) process_manager._storage.add_process(proc2) with ( patch("os.getpid", return_value=12345), patch("threading.Thread") as mock_thread, patch.object(process_manager, "stop") as mock_stop, ): result = process_manager.shutdown() assert result.pid == 12345 # Should only try to stop running processes mock_stop.assert_called_once_with(1234, force=True) mock_thread.assert_called_once() class TestProcessManagerShutdown: """Test ProcessManager.shutdown_monitor() method.""" def test_shutdown_stops_monitoring(self, process_manager): """Test that shutdown_monitor stops the monitoring thread.""" with patch.object(process_manager._storage, "stop_event_set") as mock_stop: process_manager.shutdown_monitor() mock_stop.assert_called_once() def test_shutdown_with_monitor_thread(self, fake_registry, temp_dir): """Test shutdown when monitor thread exists.""" with patch("threading.Thread") as mock_thread_cls: mock_thread = Mock() mock_thread_cls.return_value = mock_thread pm = ProcessManager( temp_dir / "server.log", monitor=True, registry=fake_registry, data_dir=temp_dir, ) pm.shutdown_monitor() # Should join the thread mock_thread.join.assert_called_once_with(timeout=2) class TestProcessLookup: """Test the process lookup functionality.""" def test_lookup_process_in_snapshot_by_pid(self, process_manager): """Test lookup by PID.""" proc = create_fake_proc_entry(pid=1234) snapshot = [proc] pid, error = process_manager._lookup_process_in_snapshot(snapshot, pid=1234) assert pid == 1234 assert error is None def test_lookup_process_in_snapshot_by_label(self, process_manager): """Test lookup by label.""" proc = create_fake_proc_entry(pid=1234, label="my-app", status="running") snapshot = [proc] pid, error = process_manager._lookup_process_in_snapshot( snapshot, label="my-app" ) assert pid == 1234 assert error is None def test_lookup_process_in_snapshot_by_command(self, process_manager): """Test lookup by command.""" proc = create_fake_proc_entry( pid=1234, command=["python", "script.py"], status="running" ) snapshot = [proc] pid, error = process_manager._lookup_process_in_snapshot( snapshot, command_or_label="python script.py" ) assert pid == 1234 assert error is None def test_lookup_process_in_snapshot_not_found(self, process_manager): """Test lookup when process not found.""" snapshot = [] pid, error = process_manager._lookup_process_in_snapshot(snapshot, pid=9999) assert pid == 9999 # PID passthrough assert error is None # PID lookup doesn't generate errors # But label lookup should generate error pid, error = process_manager._lookup_process_in_snapshot( snapshot, label="nonexistent" ) assert pid is None assert error is not None assert "No running process found" in error def test_lookup_multiple_matches_error(self, process_manager): """Test lookup error when multiple processes match command.""" proc1 = create_fake_proc_entry( pid=1234, command=["python", "script.py"], status="running" ) proc2 = create_fake_proc_entry( pid=5678, command=["python", "script.py"], status="running" ) snapshot = [proc1, proc2] pid, error = process_manager._lookup_process_in_snapshot( snapshot, command_or_label="python script.py" ) assert pid is None assert error is not None assert "Multiple processes found" in error

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/irskep/persistproc'

If you have feedback or need assistance with the MCP directory API, please join our Discord server