hatch-xclam 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hatch/__init__.py +21 -0
- hatch/cli_hatch.py +2748 -0
- hatch/environment_manager.py +1375 -0
- hatch/installers/__init__.py +25 -0
- hatch/installers/dependency_installation_orchestrator.py +636 -0
- hatch/installers/docker_installer.py +545 -0
- hatch/installers/hatch_installer.py +198 -0
- hatch/installers/installation_context.py +109 -0
- hatch/installers/installer_base.py +195 -0
- hatch/installers/python_installer.py +342 -0
- hatch/installers/registry.py +179 -0
- hatch/installers/system_installer.py +588 -0
- hatch/mcp_host_config/__init__.py +38 -0
- hatch/mcp_host_config/backup.py +458 -0
- hatch/mcp_host_config/host_management.py +572 -0
- hatch/mcp_host_config/models.py +602 -0
- hatch/mcp_host_config/reporting.py +181 -0
- hatch/mcp_host_config/strategies.py +513 -0
- hatch/package_loader.py +263 -0
- hatch/python_environment_manager.py +734 -0
- hatch/registry_explorer.py +171 -0
- hatch/registry_retriever.py +335 -0
- hatch/template_generator.py +179 -0
- hatch_xclam-0.7.0.dist-info/METADATA +150 -0
- hatch_xclam-0.7.0.dist-info/RECORD +93 -0
- hatch_xclam-0.7.0.dist-info/WHEEL +5 -0
- hatch_xclam-0.7.0.dist-info/entry_points.txt +2 -0
- hatch_xclam-0.7.0.dist-info/licenses/LICENSE +661 -0
- hatch_xclam-0.7.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/run_environment_tests.py +124 -0
- tests/test_cli_version.py +122 -0
- tests/test_data/packages/basic/base_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/base_pkg/mcp_server.py +21 -0
- tests/test_data/packages/basic/base_pkg_v2/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/base_pkg_v2/mcp_server.py +21 -0
- tests/test_data/packages/basic/utility_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/utility_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/complex_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/complex_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/docker_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/docker_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/mixed_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/mixed_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/python_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/python_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/simple_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/simple_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/system_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/system_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg_b/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg_b/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/invalid_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/invalid_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/version_conflict_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/version_conflict_pkg/mcp_server.py +21 -0
- tests/test_data/packages/schema_versions/schema_v1_1_0_pkg/main.py +11 -0
- tests/test_data/packages/schema_versions/schema_v1_2_0_pkg/main.py +11 -0
- tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/mcp_server.py +21 -0
- tests/test_data_utils.py +472 -0
- tests/test_dependency_orchestrator_consent.py +266 -0
- tests/test_docker_installer.py +524 -0
- tests/test_env_manip.py +991 -0
- tests/test_hatch_installer.py +179 -0
- tests/test_installer_base.py +221 -0
- tests/test_mcp_atomic_operations.py +276 -0
- tests/test_mcp_backup_integration.py +308 -0
- tests/test_mcp_cli_all_host_specific_args.py +303 -0
- tests/test_mcp_cli_backup_management.py +295 -0
- tests/test_mcp_cli_direct_management.py +453 -0
- tests/test_mcp_cli_discovery_listing.py +582 -0
- tests/test_mcp_cli_host_config_integration.py +823 -0
- tests/test_mcp_cli_package_management.py +360 -0
- tests/test_mcp_cli_partial_updates.py +859 -0
- tests/test_mcp_environment_integration.py +520 -0
- tests/test_mcp_host_config_backup.py +257 -0
- tests/test_mcp_host_configuration_manager.py +331 -0
- tests/test_mcp_host_registry_decorator.py +348 -0
- tests/test_mcp_pydantic_architecture_v4.py +603 -0
- tests/test_mcp_server_config_models.py +242 -0
- tests/test_mcp_server_config_type_field.py +221 -0
- tests/test_mcp_sync_functionality.py +316 -0
- tests/test_mcp_user_feedback_reporting.py +359 -0
- tests/test_non_tty_integration.py +281 -0
- tests/test_online_package_loader.py +202 -0
- tests/test_python_environment_manager.py +882 -0
- tests/test_python_installer.py +327 -0
- tests/test_registry.py +51 -0
- tests/test_registry_retriever.py +250 -0
- tests/test_system_installer.py +733 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test suite for MCP synchronization functionality (Phase 3f).
|
|
3
|
+
|
|
4
|
+
This module contains comprehensive tests for the advanced synchronization
|
|
5
|
+
features including cross-environment and cross-host synchronization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest.mock import MagicMock, patch, call
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import tempfile
|
|
12
|
+
import json
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
# Import test decorators from wobble framework
|
|
16
|
+
from wobble import integration_test, regression_test
|
|
17
|
+
|
|
18
|
+
# Import the modules we'll be testing
|
|
19
|
+
from hatch.mcp_host_config.host_management import MCPHostConfigurationManager, MCPHostType
|
|
20
|
+
from hatch.mcp_host_config.models import (
|
|
21
|
+
EnvironmentData, MCPServerConfig, SyncResult, ConfigurationResult
|
|
22
|
+
)
|
|
23
|
+
from hatch.cli_hatch import handle_mcp_sync, parse_host_list, main
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestMCPSyncConfigurations(unittest.TestCase):
|
|
27
|
+
"""Test suite for MCPHostConfigurationManager.sync_configurations() method."""
|
|
28
|
+
|
|
29
|
+
def setUp(self):
|
|
30
|
+
"""Set up test fixtures."""
|
|
31
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
32
|
+
self.manager = MCPHostConfigurationManager()
|
|
33
|
+
|
|
34
|
+
# We'll use mocks instead of real data objects to avoid validation issues
|
|
35
|
+
|
|
36
|
+
@regression_test
|
|
37
|
+
def test_sync_from_environment_to_single_host(self):
|
|
38
|
+
"""Test basic environment-to-host synchronization."""
|
|
39
|
+
with patch.object(self.manager, 'sync_configurations') as mock_sync:
|
|
40
|
+
mock_result = SyncResult(
|
|
41
|
+
success=True,
|
|
42
|
+
results=[ConfigurationResult(success=True, hostname="claude-desktop")],
|
|
43
|
+
servers_synced=2,
|
|
44
|
+
hosts_updated=1
|
|
45
|
+
)
|
|
46
|
+
mock_sync.return_value = mock_result
|
|
47
|
+
|
|
48
|
+
result = self.manager.sync_configurations(
|
|
49
|
+
from_env="test-env",
|
|
50
|
+
to_hosts=["claude-desktop"]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self.assertTrue(result.success)
|
|
54
|
+
self.assertEqual(result.servers_synced, 2)
|
|
55
|
+
self.assertEqual(result.hosts_updated, 1)
|
|
56
|
+
mock_sync.assert_called_once()
|
|
57
|
+
|
|
58
|
+
@integration_test(scope="component")
|
|
59
|
+
def test_sync_from_environment_to_multiple_hosts(self):
|
|
60
|
+
"""Test environment-to-multiple-hosts synchronization."""
|
|
61
|
+
with patch.object(self.manager, 'sync_configurations') as mock_sync:
|
|
62
|
+
mock_result = SyncResult(
|
|
63
|
+
success=True,
|
|
64
|
+
results=[
|
|
65
|
+
ConfigurationResult(success=True, hostname="claude-desktop"),
|
|
66
|
+
ConfigurationResult(success=True, hostname="cursor")
|
|
67
|
+
],
|
|
68
|
+
servers_synced=4,
|
|
69
|
+
hosts_updated=2
|
|
70
|
+
)
|
|
71
|
+
mock_sync.return_value = mock_result
|
|
72
|
+
|
|
73
|
+
result = self.manager.sync_configurations(
|
|
74
|
+
from_env="test-env",
|
|
75
|
+
to_hosts=["claude-desktop", "cursor"]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
self.assertTrue(result.success)
|
|
79
|
+
self.assertEqual(result.servers_synced, 4)
|
|
80
|
+
self.assertEqual(result.hosts_updated, 2)
|
|
81
|
+
|
|
82
|
+
@integration_test(scope="component")
|
|
83
|
+
def test_sync_from_host_to_host(self):
|
|
84
|
+
"""Test host-to-host configuration synchronization."""
|
|
85
|
+
# This test will validate the new host-to-host sync functionality
|
|
86
|
+
# that needs to be implemented
|
|
87
|
+
with patch.object(self.manager.host_registry, 'get_strategy') as mock_get_strategy:
|
|
88
|
+
mock_strategy = MagicMock()
|
|
89
|
+
mock_strategy.read_configuration.return_value = MagicMock()
|
|
90
|
+
mock_strategy.write_configuration.return_value = True
|
|
91
|
+
mock_get_strategy.return_value = mock_strategy
|
|
92
|
+
|
|
93
|
+
# Mock the sync_configurations method that we'll implement
|
|
94
|
+
with patch.object(self.manager, 'sync_configurations') as mock_sync:
|
|
95
|
+
mock_result = SyncResult(
|
|
96
|
+
success=True,
|
|
97
|
+
results=[ConfigurationResult(success=True, hostname="cursor")],
|
|
98
|
+
servers_synced=2,
|
|
99
|
+
hosts_updated=1
|
|
100
|
+
)
|
|
101
|
+
mock_sync.return_value = mock_result
|
|
102
|
+
|
|
103
|
+
result = self.manager.sync_configurations(
|
|
104
|
+
from_host="claude-desktop",
|
|
105
|
+
to_hosts=["cursor"]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
self.assertTrue(result.success)
|
|
109
|
+
self.assertEqual(result.hosts_updated, 1)
|
|
110
|
+
|
|
111
|
+
@integration_test(scope="component")
|
|
112
|
+
def test_sync_with_server_name_filter(self):
|
|
113
|
+
"""Test synchronization with specific server names."""
|
|
114
|
+
with patch.object(self.manager, 'sync_configurations') as mock_sync:
|
|
115
|
+
mock_result = SyncResult(
|
|
116
|
+
success=True,
|
|
117
|
+
results=[ConfigurationResult(success=True, hostname="claude-desktop")],
|
|
118
|
+
servers_synced=1, # Only one server due to filtering
|
|
119
|
+
hosts_updated=1
|
|
120
|
+
)
|
|
121
|
+
mock_sync.return_value = mock_result
|
|
122
|
+
|
|
123
|
+
result = self.manager.sync_configurations(
|
|
124
|
+
from_env="test-env",
|
|
125
|
+
to_hosts=["claude-desktop"],
|
|
126
|
+
servers=["weather-toolkit"]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
self.assertTrue(result.success)
|
|
130
|
+
self.assertEqual(result.servers_synced, 1)
|
|
131
|
+
|
|
132
|
+
@integration_test(scope="component")
|
|
133
|
+
def test_sync_with_pattern_filter(self):
|
|
134
|
+
"""Test synchronization with regex pattern filter."""
|
|
135
|
+
with patch.object(self.manager, 'sync_configurations') as mock_sync:
|
|
136
|
+
mock_result = SyncResult(
|
|
137
|
+
success=True,
|
|
138
|
+
results=[ConfigurationResult(success=True, hostname="claude-desktop")],
|
|
139
|
+
servers_synced=1, # Only servers matching pattern
|
|
140
|
+
hosts_updated=1
|
|
141
|
+
)
|
|
142
|
+
mock_sync.return_value = mock_result
|
|
143
|
+
|
|
144
|
+
result = self.manager.sync_configurations(
|
|
145
|
+
from_env="test-env",
|
|
146
|
+
to_hosts=["claude-desktop"],
|
|
147
|
+
pattern="weather-.*"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
self.assertTrue(result.success)
|
|
151
|
+
self.assertEqual(result.servers_synced, 1)
|
|
152
|
+
|
|
153
|
+
@regression_test
|
|
154
|
+
def test_sync_invalid_source_environment(self):
|
|
155
|
+
"""Test synchronization with non-existent source environment."""
|
|
156
|
+
with patch.object(self.manager, 'sync_configurations') as mock_sync:
|
|
157
|
+
mock_result = SyncResult(
|
|
158
|
+
success=False,
|
|
159
|
+
results=[ConfigurationResult(
|
|
160
|
+
success=False,
|
|
161
|
+
hostname="claude-desktop",
|
|
162
|
+
error_message="Environment 'nonexistent' not found"
|
|
163
|
+
)],
|
|
164
|
+
servers_synced=0,
|
|
165
|
+
hosts_updated=0
|
|
166
|
+
)
|
|
167
|
+
mock_sync.return_value = mock_result
|
|
168
|
+
|
|
169
|
+
result = self.manager.sync_configurations(
|
|
170
|
+
from_env="nonexistent",
|
|
171
|
+
to_hosts=["claude-desktop"]
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
self.assertFalse(result.success)
|
|
175
|
+
self.assertEqual(result.servers_synced, 0)
|
|
176
|
+
|
|
177
|
+
@regression_test
|
|
178
|
+
def test_sync_no_source_specified(self):
|
|
179
|
+
"""Test synchronization without source specification."""
|
|
180
|
+
with self.assertRaises(ValueError) as context:
|
|
181
|
+
self.manager.sync_configurations(to_hosts=["claude-desktop"])
|
|
182
|
+
|
|
183
|
+
self.assertIn("Must specify either from_env or from_host", str(context.exception))
|
|
184
|
+
|
|
185
|
+
@regression_test
|
|
186
|
+
def test_sync_both_sources_specified(self):
|
|
187
|
+
"""Test synchronization with both env and host sources."""
|
|
188
|
+
with self.assertRaises(ValueError) as context:
|
|
189
|
+
self.manager.sync_configurations(
|
|
190
|
+
from_env="test-env",
|
|
191
|
+
from_host="claude-desktop",
|
|
192
|
+
to_hosts=["cursor"]
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
self.assertIn("Cannot specify both from_env and from_host", str(context.exception))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class TestMCPSyncCommandParsing(unittest.TestCase):
|
|
199
|
+
"""Test suite for MCP sync command argument parsing."""
|
|
200
|
+
|
|
201
|
+
@regression_test
|
|
202
|
+
def test_sync_command_basic_parsing(self):
|
|
203
|
+
"""Test basic sync command argument parsing."""
|
|
204
|
+
test_args = [
|
|
205
|
+
'hatch', 'mcp', 'sync',
|
|
206
|
+
'--from-env', 'test-env',
|
|
207
|
+
'--to-host', 'claude-desktop'
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
with patch('sys.argv', test_args):
|
|
211
|
+
with patch('hatch.cli_hatch.HatchEnvironmentManager'):
|
|
212
|
+
with patch('hatch.cli_hatch.handle_mcp_sync', return_value=0) as mock_handler:
|
|
213
|
+
try:
|
|
214
|
+
main()
|
|
215
|
+
mock_handler.assert_called_once_with(
|
|
216
|
+
from_env='test-env',
|
|
217
|
+
from_host=None,
|
|
218
|
+
to_hosts='claude-desktop',
|
|
219
|
+
servers=None,
|
|
220
|
+
pattern=None,
|
|
221
|
+
dry_run=False,
|
|
222
|
+
auto_approve=False,
|
|
223
|
+
no_backup=False
|
|
224
|
+
)
|
|
225
|
+
except SystemExit as e:
|
|
226
|
+
self.assertEqual(e.code, 0)
|
|
227
|
+
|
|
228
|
+
@regression_test
|
|
229
|
+
def test_sync_command_with_filters(self):
|
|
230
|
+
"""Test sync command with server filters."""
|
|
231
|
+
test_args = [
|
|
232
|
+
'hatch', 'mcp', 'sync',
|
|
233
|
+
'--from-env', 'test-env',
|
|
234
|
+
'--to-host', 'claude-desktop,cursor',
|
|
235
|
+
'--servers', 'weather-api,file-manager',
|
|
236
|
+
'--dry-run'
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
with patch('sys.argv', test_args):
|
|
240
|
+
with patch('hatch.cli_hatch.HatchEnvironmentManager'):
|
|
241
|
+
with patch('hatch.cli_hatch.handle_mcp_sync', return_value=0) as mock_handler:
|
|
242
|
+
try:
|
|
243
|
+
main()
|
|
244
|
+
mock_handler.assert_called_once_with(
|
|
245
|
+
from_env='test-env',
|
|
246
|
+
from_host=None,
|
|
247
|
+
to_hosts='claude-desktop,cursor',
|
|
248
|
+
servers='weather-api,file-manager',
|
|
249
|
+
pattern=None,
|
|
250
|
+
dry_run=True,
|
|
251
|
+
auto_approve=False,
|
|
252
|
+
no_backup=False
|
|
253
|
+
)
|
|
254
|
+
except SystemExit as e:
|
|
255
|
+
self.assertEqual(e.code, 0)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TestMCPSyncCommandHandler(unittest.TestCase):
|
|
259
|
+
"""Test suite for MCP sync command handler."""
|
|
260
|
+
|
|
261
|
+
@integration_test(scope="component")
|
|
262
|
+
def test_handle_sync_environment_to_host(self):
|
|
263
|
+
"""Test sync handler for environment-to-host operation."""
|
|
264
|
+
with patch('hatch.cli_hatch.MCPHostConfigurationManager') as mock_manager_class:
|
|
265
|
+
mock_manager = MagicMock()
|
|
266
|
+
mock_result = SyncResult(
|
|
267
|
+
success=True,
|
|
268
|
+
results=[ConfigurationResult(success=True, hostname="claude-desktop")],
|
|
269
|
+
servers_synced=2,
|
|
270
|
+
hosts_updated=1
|
|
271
|
+
)
|
|
272
|
+
mock_manager.sync_configurations.return_value = mock_result
|
|
273
|
+
mock_manager_class.return_value = mock_manager
|
|
274
|
+
|
|
275
|
+
with patch('builtins.print') as mock_print:
|
|
276
|
+
with patch('hatch.cli_hatch.parse_host_list') as mock_parse:
|
|
277
|
+
with patch('hatch.cli_hatch.request_confirmation', return_value=True) as mock_confirm:
|
|
278
|
+
from hatch.mcp_host_config.models import MCPHostType
|
|
279
|
+
mock_parse.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
280
|
+
|
|
281
|
+
result = handle_mcp_sync(
|
|
282
|
+
from_env="test-env",
|
|
283
|
+
to_hosts="claude-desktop"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
self.assertEqual(result, 0)
|
|
287
|
+
mock_manager.sync_configurations.assert_called_once()
|
|
288
|
+
mock_confirm.assert_called_once()
|
|
289
|
+
|
|
290
|
+
# Verify success output
|
|
291
|
+
print_calls = [call[0][0] for call in mock_print.call_args_list]
|
|
292
|
+
self.assertTrue(any("[SUCCESS] Synchronization completed" in call for call in print_calls))
|
|
293
|
+
|
|
294
|
+
@integration_test(scope="component")
|
|
295
|
+
def test_handle_sync_dry_run(self):
|
|
296
|
+
"""Test sync handler dry-run functionality."""
|
|
297
|
+
with patch('builtins.print') as mock_print:
|
|
298
|
+
with patch('hatch.cli_hatch.parse_host_list') as mock_parse:
|
|
299
|
+
from hatch.mcp_host_config.models import MCPHostType
|
|
300
|
+
mock_parse.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
301
|
+
|
|
302
|
+
result = handle_mcp_sync(
|
|
303
|
+
from_env="test-env",
|
|
304
|
+
to_hosts="claude-desktop",
|
|
305
|
+
dry_run=True
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
self.assertEqual(result, 0)
|
|
309
|
+
|
|
310
|
+
# Verify dry-run output
|
|
311
|
+
print_calls = [call[0][0] for call in mock_print.call_args_list]
|
|
312
|
+
self.assertTrue(any("[DRY RUN] Would synchronize" in call for call in print_calls))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
if __name__ == '__main__':
|
|
316
|
+
unittest.main()
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test suite for MCP user feedback reporting system.
|
|
3
|
+
|
|
4
|
+
This module tests the FieldOperation and ConversionReport models,
|
|
5
|
+
generate_conversion_report() function, and display_report() function.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import unittest
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from io import StringIO
|
|
12
|
+
|
|
13
|
+
# Add the parent directory to the path to import wobble
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from wobble.decorators import regression_test
|
|
18
|
+
except ImportError:
|
|
19
|
+
# Fallback decorator if wobble is not available
|
|
20
|
+
def regression_test(func):
|
|
21
|
+
return func
|
|
22
|
+
|
|
23
|
+
from hatch.mcp_host_config.reporting import (
|
|
24
|
+
FieldOperation,
|
|
25
|
+
ConversionReport,
|
|
26
|
+
generate_conversion_report,
|
|
27
|
+
display_report
|
|
28
|
+
)
|
|
29
|
+
from hatch.mcp_host_config.models import (
|
|
30
|
+
MCPServerConfigOmni,
|
|
31
|
+
MCPHostType
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestFieldOperation(unittest.TestCase):
|
|
36
|
+
"""Test suite for FieldOperation model."""
|
|
37
|
+
|
|
38
|
+
@regression_test
|
|
39
|
+
def test_field_operation_updated_str_representation(self):
|
|
40
|
+
"""Test UPDATED operation string representation."""
|
|
41
|
+
field_op = FieldOperation(
|
|
42
|
+
field_name="command",
|
|
43
|
+
operation="UPDATED",
|
|
44
|
+
old_value="old_command",
|
|
45
|
+
new_value="new_command"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
result = str(field_op)
|
|
49
|
+
|
|
50
|
+
# Verify ASCII arrow used (not Unicode)
|
|
51
|
+
self.assertIn("-->", result)
|
|
52
|
+
self.assertNotIn("→", result)
|
|
53
|
+
|
|
54
|
+
# Verify format
|
|
55
|
+
self.assertEqual(result, "command: UPDATED 'old_command' --> 'new_command'")
|
|
56
|
+
|
|
57
|
+
@regression_test
|
|
58
|
+
def test_field_operation_updated_with_none_old_value(self):
|
|
59
|
+
"""Test UPDATED operation with None old_value (field added)."""
|
|
60
|
+
field_op = FieldOperation(
|
|
61
|
+
field_name="timeout",
|
|
62
|
+
operation="UPDATED",
|
|
63
|
+
old_value=None,
|
|
64
|
+
new_value=30000
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
result = str(field_op)
|
|
68
|
+
|
|
69
|
+
# Verify None is displayed
|
|
70
|
+
self.assertEqual(result, "timeout: UPDATED None --> 30000")
|
|
71
|
+
|
|
72
|
+
@regression_test
|
|
73
|
+
def test_field_operation_unsupported_str_representation(self):
|
|
74
|
+
"""Test UNSUPPORTED operation string representation."""
|
|
75
|
+
field_op = FieldOperation(
|
|
76
|
+
field_name="envFile",
|
|
77
|
+
operation="UNSUPPORTED",
|
|
78
|
+
new_value=".env"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
result = str(field_op)
|
|
82
|
+
|
|
83
|
+
# Verify format
|
|
84
|
+
self.assertEqual(result, "envFile: UNSUPPORTED")
|
|
85
|
+
|
|
86
|
+
@regression_test
|
|
87
|
+
def test_field_operation_unchanged_str_representation(self):
|
|
88
|
+
"""Test UNCHANGED operation string representation."""
|
|
89
|
+
field_op = FieldOperation(
|
|
90
|
+
field_name="name",
|
|
91
|
+
operation="UNCHANGED",
|
|
92
|
+
new_value="my-server"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
result = str(field_op)
|
|
96
|
+
|
|
97
|
+
# Verify format
|
|
98
|
+
self.assertEqual(result, "name: UNCHANGED 'my-server'")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestConversionReport(unittest.TestCase):
|
|
102
|
+
"""Test suite for ConversionReport model."""
|
|
103
|
+
|
|
104
|
+
@regression_test
|
|
105
|
+
def test_conversion_report_create_operation(self):
|
|
106
|
+
"""Test ConversionReport with create operation."""
|
|
107
|
+
report = ConversionReport(
|
|
108
|
+
operation="create",
|
|
109
|
+
server_name="my-server",
|
|
110
|
+
target_host=MCPHostType.GEMINI,
|
|
111
|
+
field_operations=[
|
|
112
|
+
FieldOperation(field_name="command", operation="UPDATED", old_value=None, new_value="python")
|
|
113
|
+
]
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
self.assertEqual(report.operation, "create")
|
|
117
|
+
self.assertEqual(report.server_name, "my-server")
|
|
118
|
+
self.assertEqual(report.target_host, MCPHostType.GEMINI)
|
|
119
|
+
self.assertTrue(report.success)
|
|
120
|
+
self.assertIsNone(report.error_message)
|
|
121
|
+
self.assertEqual(len(report.field_operations), 1)
|
|
122
|
+
self.assertFalse(report.dry_run)
|
|
123
|
+
|
|
124
|
+
@regression_test
|
|
125
|
+
def test_conversion_report_update_operation(self):
|
|
126
|
+
"""Test ConversionReport with update operation."""
|
|
127
|
+
report = ConversionReport(
|
|
128
|
+
operation="update",
|
|
129
|
+
server_name="my-server",
|
|
130
|
+
target_host=MCPHostType.VSCODE,
|
|
131
|
+
field_operations=[
|
|
132
|
+
FieldOperation(field_name="command", operation="UPDATED", old_value="old", new_value="new"),
|
|
133
|
+
FieldOperation(field_name="name", operation="UNCHANGED", new_value="my-server")
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
self.assertEqual(report.operation, "update")
|
|
138
|
+
self.assertEqual(len(report.field_operations), 2)
|
|
139
|
+
|
|
140
|
+
@regression_test
|
|
141
|
+
def test_conversion_report_migrate_operation(self):
|
|
142
|
+
"""Test ConversionReport with migrate operation."""
|
|
143
|
+
report = ConversionReport(
|
|
144
|
+
operation="migrate",
|
|
145
|
+
server_name="my-server",
|
|
146
|
+
source_host=MCPHostType.GEMINI,
|
|
147
|
+
target_host=MCPHostType.VSCODE,
|
|
148
|
+
field_operations=[]
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
self.assertEqual(report.operation, "migrate")
|
|
152
|
+
self.assertEqual(report.source_host, MCPHostType.GEMINI)
|
|
153
|
+
self.assertEqual(report.target_host, MCPHostType.VSCODE)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestGenerateConversionReport(unittest.TestCase):
|
|
157
|
+
"""Test suite for generate_conversion_report() function."""
|
|
158
|
+
|
|
159
|
+
@regression_test
|
|
160
|
+
def test_generate_report_create_operation_all_supported(self):
|
|
161
|
+
"""Test generate_conversion_report for create with all supported fields."""
|
|
162
|
+
omni = MCPServerConfigOmni(
|
|
163
|
+
name="gemini-server",
|
|
164
|
+
command="npx",
|
|
165
|
+
args=["-y", "server"],
|
|
166
|
+
cwd="/path/to/dir",
|
|
167
|
+
timeout=30000
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
report = generate_conversion_report(
|
|
171
|
+
operation="create",
|
|
172
|
+
server_name="gemini-server",
|
|
173
|
+
target_host=MCPHostType.GEMINI,
|
|
174
|
+
omni=omni
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Verify all fields are UPDATED (create operation)
|
|
178
|
+
self.assertEqual(report.operation, "create")
|
|
179
|
+
self.assertEqual(report.server_name, "gemini-server")
|
|
180
|
+
self.assertEqual(report.target_host, MCPHostType.GEMINI)
|
|
181
|
+
|
|
182
|
+
# All set fields should be UPDATED
|
|
183
|
+
updated_ops = [op for op in report.field_operations if op.operation == "UPDATED"]
|
|
184
|
+
self.assertEqual(len(updated_ops), 5) # name, command, args, cwd, timeout
|
|
185
|
+
|
|
186
|
+
# No unsupported fields
|
|
187
|
+
unsupported_ops = [op for op in report.field_operations if op.operation == "UNSUPPORTED"]
|
|
188
|
+
self.assertEqual(len(unsupported_ops), 0)
|
|
189
|
+
|
|
190
|
+
@regression_test
|
|
191
|
+
def test_generate_report_create_operation_with_unsupported(self):
|
|
192
|
+
"""Test generate_conversion_report with unsupported fields."""
|
|
193
|
+
omni = MCPServerConfigOmni(
|
|
194
|
+
name="gemini-server",
|
|
195
|
+
command="python",
|
|
196
|
+
cwd="/path/to/dir", # Gemini field
|
|
197
|
+
envFile=".env" # VS Code field (unsupported by Gemini)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
report = generate_conversion_report(
|
|
201
|
+
operation="create",
|
|
202
|
+
server_name="gemini-server",
|
|
203
|
+
target_host=MCPHostType.GEMINI,
|
|
204
|
+
omni=omni
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Verify Gemini fields are UPDATED
|
|
208
|
+
updated_ops = [op for op in report.field_operations if op.operation == "UPDATED"]
|
|
209
|
+
updated_fields = {op.field_name for op in updated_ops}
|
|
210
|
+
self.assertIn("name", updated_fields)
|
|
211
|
+
self.assertIn("command", updated_fields)
|
|
212
|
+
self.assertIn("cwd", updated_fields)
|
|
213
|
+
|
|
214
|
+
# Verify VS Code field is UNSUPPORTED
|
|
215
|
+
unsupported_ops = [op for op in report.field_operations if op.operation == "UNSUPPORTED"]
|
|
216
|
+
self.assertEqual(len(unsupported_ops), 1)
|
|
217
|
+
self.assertEqual(unsupported_ops[0].field_name, "envFile")
|
|
218
|
+
|
|
219
|
+
@regression_test
|
|
220
|
+
def test_generate_report_update_operation(self):
|
|
221
|
+
"""Test generate_conversion_report for update operation."""
|
|
222
|
+
old_config = MCPServerConfigOmni(
|
|
223
|
+
name="my-server",
|
|
224
|
+
command="python",
|
|
225
|
+
args=["old.py"]
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
new_omni = MCPServerConfigOmni(
|
|
229
|
+
name="my-server",
|
|
230
|
+
command="python",
|
|
231
|
+
args=["new.py"]
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
report = generate_conversion_report(
|
|
235
|
+
operation="update",
|
|
236
|
+
server_name="my-server",
|
|
237
|
+
target_host=MCPHostType.GEMINI,
|
|
238
|
+
omni=new_omni,
|
|
239
|
+
old_config=old_config
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Verify name and command are UNCHANGED
|
|
243
|
+
unchanged_ops = [op for op in report.field_operations if op.operation == "UNCHANGED"]
|
|
244
|
+
unchanged_fields = {op.field_name for op in unchanged_ops}
|
|
245
|
+
self.assertIn("name", unchanged_fields)
|
|
246
|
+
self.assertIn("command", unchanged_fields)
|
|
247
|
+
|
|
248
|
+
# Verify args is UPDATED
|
|
249
|
+
updated_ops = [op for op in report.field_operations if op.operation == "UPDATED"]
|
|
250
|
+
self.assertEqual(len(updated_ops), 1)
|
|
251
|
+
self.assertEqual(updated_ops[0].field_name, "args")
|
|
252
|
+
self.assertEqual(updated_ops[0].old_value, ["old.py"])
|
|
253
|
+
self.assertEqual(updated_ops[0].new_value, ["new.py"])
|
|
254
|
+
|
|
255
|
+
@regression_test
|
|
256
|
+
def test_generate_report_dynamic_field_derivation(self):
|
|
257
|
+
"""Test that generate_conversion_report uses dynamic field derivation."""
|
|
258
|
+
omni = MCPServerConfigOmni(
|
|
259
|
+
name="test-server",
|
|
260
|
+
command="python"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Generate report for Gemini
|
|
264
|
+
report_gemini = generate_conversion_report(
|
|
265
|
+
operation="create",
|
|
266
|
+
server_name="test-server",
|
|
267
|
+
target_host=MCPHostType.GEMINI,
|
|
268
|
+
omni=omni
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# All fields should be UPDATED (no unsupported)
|
|
272
|
+
unsupported_ops = [op for op in report_gemini.field_operations if op.operation == "UNSUPPORTED"]
|
|
273
|
+
self.assertEqual(len(unsupported_ops), 0)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class TestDisplayReport(unittest.TestCase):
|
|
277
|
+
"""Test suite for display_report() function."""
|
|
278
|
+
|
|
279
|
+
@regression_test
|
|
280
|
+
def test_display_report_create_operation(self):
|
|
281
|
+
"""Test display_report for create operation."""
|
|
282
|
+
report = ConversionReport(
|
|
283
|
+
operation="create",
|
|
284
|
+
server_name="my-server",
|
|
285
|
+
target_host=MCPHostType.GEMINI,
|
|
286
|
+
field_operations=[
|
|
287
|
+
FieldOperation(field_name="command", operation="UPDATED", old_value=None, new_value="python")
|
|
288
|
+
]
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Capture stdout
|
|
292
|
+
captured_output = StringIO()
|
|
293
|
+
sys.stdout = captured_output
|
|
294
|
+
|
|
295
|
+
display_report(report)
|
|
296
|
+
|
|
297
|
+
sys.stdout = sys.__stdout__
|
|
298
|
+
output = captured_output.getvalue()
|
|
299
|
+
|
|
300
|
+
# Verify header
|
|
301
|
+
self.assertIn("Server 'my-server' created for host", output)
|
|
302
|
+
self.assertIn("gemini", output.lower())
|
|
303
|
+
|
|
304
|
+
# Verify field operation displayed
|
|
305
|
+
self.assertIn("command: UPDATED", output)
|
|
306
|
+
|
|
307
|
+
@regression_test
|
|
308
|
+
def test_display_report_update_operation(self):
|
|
309
|
+
"""Test display_report for update operation."""
|
|
310
|
+
report = ConversionReport(
|
|
311
|
+
operation="update",
|
|
312
|
+
server_name="my-server",
|
|
313
|
+
target_host=MCPHostType.VSCODE,
|
|
314
|
+
field_operations=[
|
|
315
|
+
FieldOperation(field_name="args", operation="UPDATED", old_value=["old.py"], new_value=["new.py"])
|
|
316
|
+
]
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Capture stdout
|
|
320
|
+
captured_output = StringIO()
|
|
321
|
+
sys.stdout = captured_output
|
|
322
|
+
|
|
323
|
+
display_report(report)
|
|
324
|
+
|
|
325
|
+
sys.stdout = sys.__stdout__
|
|
326
|
+
output = captured_output.getvalue()
|
|
327
|
+
|
|
328
|
+
# Verify header
|
|
329
|
+
self.assertIn("Server 'my-server' updated for host", output)
|
|
330
|
+
|
|
331
|
+
@regression_test
|
|
332
|
+
def test_display_report_dry_run(self):
|
|
333
|
+
"""Test display_report for dry-run mode."""
|
|
334
|
+
report = ConversionReport(
|
|
335
|
+
operation="create",
|
|
336
|
+
server_name="my-server",
|
|
337
|
+
target_host=MCPHostType.GEMINI,
|
|
338
|
+
field_operations=[],
|
|
339
|
+
dry_run=True
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Capture stdout
|
|
343
|
+
captured_output = StringIO()
|
|
344
|
+
sys.stdout = captured_output
|
|
345
|
+
|
|
346
|
+
display_report(report)
|
|
347
|
+
|
|
348
|
+
sys.stdout = sys.__stdout__
|
|
349
|
+
output = captured_output.getvalue()
|
|
350
|
+
|
|
351
|
+
# Verify dry-run header and footer
|
|
352
|
+
self.assertIn("[DRY RUN]", output)
|
|
353
|
+
self.assertIn("Preview of changes", output)
|
|
354
|
+
self.assertIn("No changes were made", output)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == '__main__':
|
|
358
|
+
unittest.main()
|
|
359
|
+
|