|
12 | 12 | import tempfile |
13 | 13 | import urllib.error |
14 | 14 | from pathlib import Path |
| 15 | +from typing import Any |
15 | 16 | from unittest.mock import MagicMock |
16 | 17 | from unittest.mock import patch |
17 | 18 |
|
@@ -3952,3 +3953,195 @@ def test_windows_removal_all_scopes( |
3952 | 3953 |
|
3953 | 3954 | # All three scopes should be attempted |
3954 | 3955 | assert scopes_removed == {'user', 'local', 'project'} |
| 3956 | + |
| 3957 | + |
| 3958 | +class TestConfigureMcpServerBestEffortRemoval: |
| 3959 | + """Test configure_mcp_server best-effort removal (ignores exit codes).""" |
| 3960 | + |
| 3961 | + @patch('platform.system', return_value='Windows') |
| 3962 | + @patch('setup_environment.find_command_robust', return_value='claude') |
| 3963 | + @patch('setup_environment.run_bash_command') |
| 3964 | + def test_windows_best_effort_removal_non_existent_server( |
| 3965 | + self, |
| 3966 | + mock_bash: MagicMock, |
| 3967 | + mock_find: MagicMock, |
| 3968 | + _mock_system: MagicMock, |
| 3969 | + ) -> None: |
| 3970 | + """Test Windows removal ignores non-zero exit code for non-existent servers.""" |
| 3971 | + del mock_find, _mock_system |
| 3972 | + |
| 3973 | + # Removal returns non-zero (server doesn't exist), add succeeds |
| 3974 | + def bash_side_effect(cmd: str, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]: |
| 3975 | + del args, kwargs |
| 3976 | + if 'mcp remove' in cmd: |
| 3977 | + return subprocess.CompletedProcess([], 1, '', 'No user-scoped MCP server found') |
| 3978 | + return subprocess.CompletedProcess([], 0, '', '') |
| 3979 | + |
| 3980 | + mock_bash.side_effect = bash_side_effect |
| 3981 | + |
| 3982 | + server = { |
| 3983 | + 'name': 'new-server', |
| 3984 | + 'command': 'uvx new-server', |
| 3985 | + 'scope': 'user', |
| 3986 | + } |
| 3987 | + |
| 3988 | + result = setup_environment.configure_mcp_server(server) |
| 3989 | + |
| 3990 | + assert result is True # Should succeed despite removal returning non-zero |
| 3991 | + # Verify removal was attempted for all scopes |
| 3992 | + removal_calls = [c for c in mock_bash.call_args_list if 'mcp remove' in str(c)] |
| 3993 | + assert len(removal_calls) == 3 # user, local, project |
| 3994 | + |
| 3995 | + @patch('platform.system', return_value='Linux') |
| 3996 | + @patch('setup_environment.find_command_robust', return_value='claude') |
| 3997 | + @patch('setup_environment.run_command') |
| 3998 | + def test_unix_best_effort_removal_non_existent_server( |
| 3999 | + self, |
| 4000 | + mock_run: MagicMock, |
| 4001 | + mock_find: MagicMock, |
| 4002 | + _mock_system: MagicMock, |
| 4003 | + ) -> None: |
| 4004 | + """Test Unix removal ignores non-zero exit code for non-existent servers.""" |
| 4005 | + del mock_find, _mock_system |
| 4006 | + |
| 4007 | + # Removal returns non-zero (server doesn't exist), add succeeds |
| 4008 | + def run_side_effect( |
| 4009 | + cmd: list[str], *args: Any, **kwargs: Any, |
| 4010 | + ) -> subprocess.CompletedProcess[str]: |
| 4011 | + del args, kwargs |
| 4012 | + if 'remove' in cmd: |
| 4013 | + return subprocess.CompletedProcess([], 1, '', 'No user-scoped MCP server found') |
| 4014 | + return subprocess.CompletedProcess([], 0, '', '') |
| 4015 | + |
| 4016 | + mock_run.side_effect = run_side_effect |
| 4017 | + |
| 4018 | + server = { |
| 4019 | + 'name': 'new-server', |
| 4020 | + 'command': 'uvx new-server', |
| 4021 | + 'scope': 'user', |
| 4022 | + } |
| 4023 | + |
| 4024 | + result = setup_environment.configure_mcp_server(server) |
| 4025 | + |
| 4026 | + assert result is True # Should succeed despite removal returning non-zero |
| 4027 | + |
| 4028 | + @patch('platform.system', return_value='Linux') |
| 4029 | + @patch('setup_environment.find_command_robust', return_value='claude') |
| 4030 | + @patch('setup_environment.run_command') |
| 4031 | + def test_removal_attempts_all_scopes_regardless_of_exit_codes( |
| 4032 | + self, |
| 4033 | + mock_run: MagicMock, |
| 4034 | + mock_find: MagicMock, |
| 4035 | + _mock_system: MagicMock, |
| 4036 | + ) -> None: |
| 4037 | + """Test that removal is attempted for all scopes even when some fail.""" |
| 4038 | + del mock_find, _mock_system |
| 4039 | + |
| 4040 | + removal_attempts: list[str] = [] |
| 4041 | + |
| 4042 | + def run_side_effect( |
| 4043 | + cmd: list[str], *args: Any, **kwargs: Any, |
| 4044 | + ) -> subprocess.CompletedProcess[str]: |
| 4045 | + del args, kwargs |
| 4046 | + if 'remove' in cmd: |
| 4047 | + scope_idx = cmd.index('--scope') + 1 if '--scope' in cmd else -1 |
| 4048 | + if scope_idx > 0: |
| 4049 | + removal_attempts.append(cmd[scope_idx]) |
| 4050 | + # First scope fails, others succeed |
| 4051 | + if len(removal_attempts) == 1: |
| 4052 | + return subprocess.CompletedProcess([], 1, '', 'Not found') |
| 4053 | + return subprocess.CompletedProcess([], 0, '', '') |
| 4054 | + |
| 4055 | + mock_run.side_effect = run_side_effect |
| 4056 | + |
| 4057 | + server = { |
| 4058 | + 'name': 'test-server', |
| 4059 | + 'command': 'uvx test-server', |
| 4060 | + 'scope': 'user', |
| 4061 | + } |
| 4062 | + |
| 4063 | + result = setup_environment.configure_mcp_server(server) |
| 4064 | + |
| 4065 | + assert result is True |
| 4066 | + # All three scopes should have been attempted |
| 4067 | + assert set(removal_attempts) == {'user', 'local', 'project'} |
| 4068 | + |
| 4069 | + |
| 4070 | +class TestMcpServersSummaryDisplay: |
| 4071 | + """Test MCP servers summary display format in main().""" |
| 4072 | + |
| 4073 | + def test_summary_shows_profile_only_when_combined_exists( |
| 4074 | + self, |
| 4075 | + capsys: pytest.CaptureFixture[str], |
| 4076 | + ) -> None: |
| 4077 | + """Test summary displays profile-only servers when combined servers exist.""" |
| 4078 | + # Stats: 5 global, 10 profile, 5 combined -> 5 profile-only |
| 4079 | + mcp_stats = {'global_count': 5, 'profile_count': 10, 'combined_count': 5} |
| 4080 | + |
| 4081 | + # Simulate the display logic from main() |
| 4082 | + if mcp_stats['combined_count'] > 0: |
| 4083 | + profile_only = mcp_stats['profile_count'] - mcp_stats['combined_count'] |
| 4084 | + if profile_only > 0: |
| 4085 | + print(f" * MCP servers: {mcp_stats['global_count']} global " |
| 4086 | + f"({mcp_stats['combined_count']} also in profile), " |
| 4087 | + f"{profile_only} profile-only") |
| 4088 | + else: |
| 4089 | + print(f" * MCP servers: {mcp_stats['global_count']} global " |
| 4090 | + f"(all {mcp_stats['combined_count']} also in profile)") |
| 4091 | + |
| 4092 | + captured = capsys.readouterr() |
| 4093 | + # Verify the output includes profile-only count |
| 4094 | + assert '5 global' in captured.out |
| 4095 | + assert '5 also in profile' in captured.out |
| 4096 | + assert '5 profile-only' in captured.out |
| 4097 | + |
| 4098 | + def test_summary_shows_all_in_profile_when_no_profile_only( |
| 4099 | + self, |
| 4100 | + capsys: pytest.CaptureFixture[str], |
| 4101 | + ) -> None: |
| 4102 | + """Test summary shows 'all X also in profile' when profile_only is 0.""" |
| 4103 | + # Stats: 5 global, 5 profile, 5 combined -> 0 profile-only |
| 4104 | + mcp_stats = {'global_count': 5, 'profile_count': 5, 'combined_count': 5} |
| 4105 | + |
| 4106 | + # Simulate the display logic from main() |
| 4107 | + if mcp_stats['combined_count'] > 0: |
| 4108 | + profile_only = mcp_stats['profile_count'] - mcp_stats['combined_count'] |
| 4109 | + if profile_only > 0: |
| 4110 | + print(f" * MCP servers: {mcp_stats['global_count']} global " |
| 4111 | + f"({mcp_stats['combined_count']} also in profile), " |
| 4112 | + f"{profile_only} profile-only") |
| 4113 | + else: |
| 4114 | + print(f" * MCP servers: {mcp_stats['global_count']} global " |
| 4115 | + f"(all {mcp_stats['combined_count']} also in profile)") |
| 4116 | + |
| 4117 | + captured = capsys.readouterr() |
| 4118 | + assert '5 global' in captured.out |
| 4119 | + assert 'all 5 also in profile' in captured.out |
| 4120 | + assert 'profile-only' not in captured.out |
| 4121 | + |
| 4122 | + def test_summary_handles_profile_only_without_combined( |
| 4123 | + self, |
| 4124 | + capsys: pytest.CaptureFixture[str], |
| 4125 | + ) -> None: |
| 4126 | + """Test summary displays profile-only servers when no combined servers.""" |
| 4127 | + # Stats: 3 global, 5 profile, 0 combined -> all 5 are profile-only |
| 4128 | + mcp_stats = {'global_count': 3, 'profile_count': 5, 'combined_count': 0} |
| 4129 | + profile_servers = [{'name': f'server-{i}'} for i in range(5)] |
| 4130 | + |
| 4131 | + # Simulate the display logic from main() |
| 4132 | + if mcp_stats['combined_count'] > 0: |
| 4133 | + profile_only = mcp_stats['profile_count'] - mcp_stats['combined_count'] |
| 4134 | + if profile_only > 0: |
| 4135 | + print(f" * MCP servers: {mcp_stats['global_count']} global " |
| 4136 | + f"({mcp_stats['combined_count']} also in profile), " |
| 4137 | + f"{profile_only} profile-only") |
| 4138 | + else: |
| 4139 | + print(f" * MCP servers: {mcp_stats['global_count']} global " |
| 4140 | + f"(all {mcp_stats['combined_count']} also in profile)") |
| 4141 | + elif profile_servers: |
| 4142 | + print(f" * MCP servers: {mcp_stats['global_count']} global, " |
| 4143 | + f"{mcp_stats['profile_count']} profile-only") |
| 4144 | + |
| 4145 | + captured = capsys.readouterr() |
| 4146 | + assert '3 global' in captured.out |
| 4147 | + assert '5 profile-only' in captured.out |
0 commit comments