Xcode Diagnostics MCP Plugin

by leftspin
Verified
#!/usr/bin/env python3 """ Test suite for the Xcode Diagnostics MCP Plugin """ import unittest import os import tempfile import json import shutil from unittest.mock import patch, MagicMock from pathlib import Path # Make sure we can import the module import sys sys.path.append(os.path.dirname(os.path.abspath(__file__))) from xcode_diagnostics import ( XcodeDiagnostics, DiagnosticIssue, get_xcode_projects, get_project_diagnostics ) class TestXcodeDiagnostics(unittest.TestCase): """Test cases for XcodeDiagnostics class and functions""" def setUp(self): # Create a temporary directory structure that mimics DerivedData self.temp_dir = tempfile.mkdtemp() self.derived_data_path = os.path.join(self.temp_dir, "DerivedData") os.makedirs(self.derived_data_path) # Create mock project structures self.project1_path = os.path.join(self.derived_data_path, "TestProject1-abc123") self.project2_path = os.path.join(self.derived_data_path, "TestProject2-def456") os.makedirs(os.path.join(self.project1_path, "Logs", "Build")) os.makedirs(os.path.join(self.project2_path, "Logs", "Build")) # Create mock log files self.log1_path = os.path.join(self.project1_path, "Logs", "Build", "log1.xcactivitylog") self.log2_path = os.path.join(self.project2_path, "Logs", "Build", "log2.xcactivitylog") # Touch the files Path(self.log1_path).touch() Path(self.log2_path).touch() def tearDown(self): # Clean up the temporary directory shutil.rmtree(self.temp_dir) def test_get_xcode_projects(self): """Test that get_xcode_projects returns properly formatted JSON""" # Call the function directly result = get_xcode_projects() # Check result result_dict = json.loads(result) self.assertIn("projects", result_dict) # Just check that we get a valid response with some projects self.assertGreaterEqual(len(result_dict["projects"]), 1) def test_get_project_diagnostics(self): """Test that get_project_diagnostics returns properly formatted JSON""" # We need a valid project name for this test to work # So let's get a real project from the system first projects_json = get_xcode_projects() projects_dict = json.loads(projects_json) # Find a project with build logs test_project = None for project in projects_dict["projects"]: if project.get("has_build_logs", False): test_project = project["directory_name"] break # Skip test if no valid project found if not test_project: self.skipTest("No projects with build logs found for testing") # Call the function with a real project result = get_project_diagnostics(test_project) # Check result result_dict = json.loads(result) # Just verify the structure, not the exact content self.assertIn("success", result_dict) self.assertIn("errors", result_dict) self.assertIn("warnings", result_dict) self.assertIn("error_count", result_dict) self.assertIn("warning_count", result_dict) # The test_get_most_recent_project_diagnostics method has been removed # as that functionality is no longer needed @patch('subprocess.check_output') def test_parse_log_file(self, mock_subprocess): """Test parsing of log file content with realistic Xcode error and warning formats""" # Create an instance with our test directory diagnostics = XcodeDiagnostics() diagnostics.derived_data_path = self.derived_data_path # Mock a realistic Xcode build log with errors and warnings mock_output = """ SwiftCompile normal arm64 /Users/developer/MyProject/Sources/App/AppDelegate.swift cd /Users/developer/MyProject /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift -frontend -c -primary-file /Users/developer/MyProject/Sources/App/AppDelegate.swift:25:18: error: use of unresolved identifier 'AppConfiguration' let config = AppConfiguration() ^~~~~~~~~~~~~~~~ /Users/developer/MyProject/Sources/App/AppDelegate.swift:25:18: note: did you mean 'URLSessionConfiguration'? let config = AppConfiguration() ^~~~~~~~~~~~~~~~ URLSessionConfiguration /Users/developer/MyProject/Sources/App/ViewController.swift:42:10: warning: result of call to 'loadView()' is unused self.loadView() ^~~~~~~~~~~~ /Users/developer/MyProject/Sources/App/ViewController.swift:48:27: warning: string interpolation produces a debug description for an optional value; did you mean to make this explicit? print("User name: \\(user.name)") ^~~~~~~~~~~ /Users/developer/MyProject/Sources/App/ViewController.swift:53:14: error: value of type 'UIView' has no member 'setText' myView.setText("Hello World") ~~~~~~ ^~~~~~~ /Users/developer/MyProject/Sources/Services/NetworkManager.swift:112:40: warning: initialization of immutable value 'response' was never used let data = responseData, let response = httpResponse { ^~~~~~~~ /Users/developer/MyProject/Sources/Services/NetworkManager.swift:122:22: error: cannot convert value of type 'String' to expected argument type 'URL' let task = session.dataTask(with: "https://api.example.com") ^~~~~~~~~~~~~~~~~~~~~~~~~~~ """ mock_subprocess.return_value = mock_output # Call the parse method issues = diagnostics._parse_log_file(self.log1_path, include_warnings=True) # Verify results - our enhanced extraction might find additional issues self.assertGreaterEqual(len(issues), 6, "Should find at least 3 errors and 3 warnings") # Count errors and warnings errors = [issue for issue in issues if issue.type == "error"] warnings = [issue for issue in issues if issue.type == "warning"] self.assertGreaterEqual(len(errors), 3, "Should find at least 3 errors") self.assertGreaterEqual(len(warnings), 3, "Should find at least 3 warnings") # Verify we found the specific errors we expect app_config_error_count = 0 set_text_error_count = 0 string_error_count = 0 for error in errors: if error.file_path == "/Users/developer/MyProject/Sources/App/AppDelegate.swift" and "AppConfiguration" in error.message: app_config_error_count += 1 elif error.file_path == "/Users/developer/MyProject/Sources/App/ViewController.swift" and "setText" in error.message: set_text_error_count += 1 elif error.file_path == "/Users/developer/MyProject/Sources/Services/NetworkManager.swift" and "String" in error.message: string_error_count += 1 self.assertGreaterEqual(app_config_error_count, 1, "Should find AppConfiguration error") self.assertGreaterEqual(set_text_error_count, 1, "Should find setText error") self.assertGreaterEqual(string_error_count, 1, "Should find String to URL error") # Check specific error details app_config_error = next((e for e in errors if "AppConfiguration" in e.message), None) self.assertIsNotNone(app_config_error) self.assertEqual(app_config_error.file_path, "/Users/developer/MyProject/Sources/App/AppDelegate.swift") self.assertEqual(app_config_error.line_number, 25) self.assertEqual(app_config_error.column, 18) # Check specific warning details unused_warning = next((w for w in warnings if "unused" in w.message), None) self.assertIsNotNone(unused_warning) # The unused warning in our mock data is in ViewController.swift for 'loadView()' self.assertEqual(unused_warning.file_path, "/Users/developer/MyProject/Sources/App/ViewController.swift") self.assertEqual(unused_warning.line_number, 42) self.assertEqual(unused_warning.column, 10) # Test with warnings excluded issues_no_warnings = diagnostics._parse_log_file(self.log1_path, include_warnings=False) # Our enhanced extraction may find variants of the same error, so we just verify: # 1. We have errors (at least as many as expected) # 2. None of them are warnings self.assertGreaterEqual(len(issues_no_warnings), 3, "Should find at least 3 errors when warnings are excluded") # Verify no warnings are included warnings_when_excluded = [issue for issue in issues_no_warnings if issue.type == "warning"] self.assertEqual(len(warnings_when_excluded), 0, "Should not find any warnings when excluded") def test_get_latest_build_log(self): """Test getting the latest build log file""" # Create an instance with our test directory diagnostics = XcodeDiagnostics() diagnostics.derived_data_path = self.derived_data_path # Create two log files with different timestamps recent_log = os.path.join(self.project1_path, "Logs", "Build", "recent.xcactivitylog") older_log = os.path.join(self.project1_path, "Logs", "Build", "older.xcactivitylog") Path(recent_log).touch() Path(older_log).touch() # Make one file newer than the other now = Path(recent_log).stat().st_mtime os.utime(older_log, (now - 100, now - 100)) # Get the latest log latest_log = diagnostics.get_latest_build_log("TestProject1-abc123") # Verify it's the recent one self.assertEqual(os.path.basename(latest_log), "recent.xcactivitylog") @patch('subprocess.check_output') def test_extract_diagnostics_end_to_end(self, mock_subprocess): """Test the entire flow from extracting to formatting diagnostics""" # Create an instance with our test directory diagnostics = XcodeDiagnostics() diagnostics.derived_data_path = self.derived_data_path # Create a realistic Xcode build log with a mix of errors and warnings # This simulates the output from a real build mock_output = """ /Users/developer/TestApp/AppDelegate.swift:15:10: error: missing required module 'UIKit' import UIKit ^ /Users/developer/TestApp/ViewController.swift:32:21: warning: implicit conversion loses integer precision: 'Int' to 'Int16' let smallValue: Int16 = bigValue ^ ~~~~~~~ /Users/developer/TestApp/Models/User.swift:45:18: error: property 'name' with type 'String' cannot be used in a generic context expecting 'Int' return compare(user.name, 42) ^~~~~~~~~~ """ mock_subprocess.return_value = mock_output # Test the complete extraction flow result = diagnostics.extract_diagnostics("TestProject1-abc123", include_warnings=True) # Verify the structure and content of the result self.assertTrue(result["success"]) self.assertGreaterEqual(result["error_count"], 2, "Should find at least 2 errors") self.assertGreaterEqual(result["warning_count"], 1, "Should find at least 1 warning") # Verify error details are preserved errors = result["errors"] self.assertGreaterEqual(len(errors), 2, "Should have at least 2 error objects") # Check the first error self.assertEqual(errors[0]["type"], "error") self.assertEqual(errors[0]["file_path"], "/Users/developer/TestApp/AppDelegate.swift") self.assertEqual(errors[0]["line_number"], 15) self.assertEqual(errors[0]["column"], 10) self.assertIn("missing required module", errors[0]["message"]) # Check the warnings warnings = result["warnings"] self.assertGreaterEqual(len(warnings), 1, "Should have at least 1 warning") # Verify at least one warning matches our expected pattern expected_warning_found = False for warning in warnings: if (warning["type"] == "warning" and warning["file_path"] == "/Users/developer/TestApp/ViewController.swift" and "implicit conversion loses integer precision" in warning["message"]): expected_warning_found = True break self.assertTrue(expected_warning_found, "Should find the implicit conversion warning") # Test with warnings excluded result_no_warnings = diagnostics.extract_diagnostics("TestProject1-abc123", include_warnings=False) self.assertGreaterEqual(result_no_warnings["error_count"], 2, "Should have at least 2 errors") self.assertEqual(result_no_warnings["warning_count"], 0, "Should have no warnings when excluded") self.assertEqual(len(result_no_warnings["warnings"]), 0, "Warnings list should be empty") def test_with_sample_file(self): """Test using the included sample file to verify parsing with real-world data""" # Create an instance with our test directory diagnostics = XcodeDiagnostics() diagnostics.derived_data_path = self.derived_data_path # Create a mock log file that we'll read from the test_data directory sample_log_path = os.path.join(self.project1_path, "Logs", "Build", "sample.xcactivitylog") Path(sample_log_path).touch() # Get the sample data file path script_dir = os.path.dirname(os.path.abspath(__file__)) sample_data_path = os.path.join(script_dir, "test_data", "sample_xcode_log.txt") # Use the sample file instead of real subprocess call with patch('subprocess.check_output') as mock_subprocess: with open(sample_data_path, 'r') as f: mock_subprocess.return_value = f.read() # Test the extraction result = diagnostics.extract_diagnostics("TestProject1-abc123", include_warnings=True) # Verify we found at least the minimum number of errors and warnings # Note: The actual count may vary slightly due to extraction improvements self.assertGreaterEqual(result["error_count"], 5, "Should find at least 5 errors in the sample data") self.assertGreaterEqual(result["warning_count"], 4, "Should find at least 4 warnings in the sample data") # Double-check that we have at least the expected number of items in the lists self.assertGreaterEqual(len(result["errors"]), 5, "Should have at least 5 error objects in the errors list") self.assertGreaterEqual(len(result["warnings"]), 4, "Should have at least 4 warning objects in the warnings list") # Verify error types and locations errors = result["errors"] warnings = result["warnings"] # Check that we found errors in all the expected files error_files = {error["file_path"] for error in errors} expected_error_files = { "/Users/developer/TestApp/AppDelegate.swift", "/Users/developer/TestApp/ViewController.swift", "/Users/developer/TestApp/Models/User.swift", "/Users/developer/TestApp/Services/NetworkManager.swift" } self.assertTrue(expected_error_files.issubset(error_files), f"Expected to find errors in {expected_error_files}, but found {error_files}") # Check for specific error messages error_messages = [error["message"] for error in errors] self.assertTrue(any("missing required module" in msg for msg in error_messages)) self.assertTrue(any("setText" in msg for msg in error_messages)) # Check for specific warning types warning_messages = [w["message"] for w in warnings] self.assertTrue(any("implicit conversion" in msg for msg in warning_messages)) self.assertTrue(any("unused" in msg for msg in warning_messages)) def test_duplicate_getter_detection(self): """Test detection of 'variable already has a getter' errors.""" # Create an instance with our test directory diagnostics = XcodeDiagnostics() diagnostics.derived_data_path = self.derived_data_path # Create a mock log file sample_log_path = os.path.join(self.project1_path, "Logs", "Build", "getter_error.xcactivitylog") Path(sample_log_path).touch() # Get the test data file path for the getter error script_dir = os.path.dirname(os.path.abspath(__file__)) getter_error_path = os.path.join(script_dir, "test_data", "duplicate_getter_error.txt") # Use the getter error file for the subprocess call with patch('subprocess.check_output') as mock_subprocess: # Set up the mock to return our test data with open(getter_error_path, 'r') as f: mock_subprocess.return_value = f.read() # Test the extraction result = diagnostics.extract_diagnostics("TestProject1-abc123", include_warnings=True) # Verify we found at least one error self.assertGreaterEqual(result["error_count"], 1, "Should find at least 1 error in the duplicate getter test data") self.assertGreaterEqual(len(result["errors"]), 1, "Should have at least 1 error object in the errors list") # Check that we found the specific getter error found_getter_error = False found_getter_note = False for error in result["errors"]: if (error["type"] == "error" and error["file_path"] == "/Users/mike/src/Pantheon/Pantheon/Core/Cortex/Cortex.swift" and error["line_number"] == 543 and "variable already has a getter" in error["message"]): found_getter_error = True # Check for the related note, if present in this error's notes if error.get("notes"): for note in error["notes"]: if (note["type"] == "note" and note["file_path"] == "/Users/mike/src/Pantheon/Pantheon/Core/Cortex/Cortex.swift" and note["line_number"] == 538 and "previous definition" in note["message"]): found_getter_note = True self.assertTrue(found_getter_error, "Should find the specific 'variable already has a getter' error") # Verify the note about previous definition is captured (if we found it) # Note: this is not required to pass the test, as it depends on how the diagnostics are structured if found_getter_note: self.assertTrue(found_getter_note, "Found the 'previous definition' note") def test_generic_error_detection(self): """Test detection of generic error formats like 'Multiple commands produce'.""" # Create an instance with our test directory diagnostics = XcodeDiagnostics() diagnostics.derived_data_path = self.derived_data_path # Create a mock log file sample_log_path = os.path.join(self.project1_path, "Logs", "Build", "generic_error.xcactivitylog") Path(sample_log_path).touch() # Create mock error data with the 'Multiple commands produce' format mock_error_data = """ SwiftCompile normal arm64 /Users/mike/Library/Developer/Xcode/DerivedData/Pantheon/Build/Intermediates.noindex/Pantheon.build cd /Users/mike/src/Pantheon /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift error: Multiple commands produce '/Users/mike/Library/Developer/Xcode/DerivedData/Pantheon-cqmfovbfsjnwlzdvjocpxwkyoofe/Build/Intermediates.noindex/Pantheon.build/Debug-xros/Pantheon.build/Objects-normal/arm64/ToolRegistry.stringsdata' note: Target 'Pantheon' (project 'Pantheon') has Swift tasks not blocking downstream targets note: Target 'Pantheon' (project 'Pantheon') has Swift tasks not blocking downstream targets error: Multiple commands produce '/Users/mike/Library/Developer/Xcode/DerivedData/Pantheon-cqmfovbfsjnwlzdvjocpxwkyoofe/Build/Intermediates.noindex/Pantheon.build/Debug-xros/Pantheon.build/Objects-normal/arm64/Tool.stringsdata' note: Target 'Pantheon' (project 'Pantheon') has Swift tasks not blocking downstream targets note: Target 'Pantheon' (project 'Pantheon') has Swift tasks not blocking downstream targets """ # Use the mock error data for the subprocess call with patch('subprocess.check_output') as mock_subprocess: mock_subprocess.return_value = mock_error_data # Test the extraction result = diagnostics.extract_diagnostics("TestProject1-abc123", include_warnings=True) # Verify we found at least the two generic errors self.assertGreaterEqual(result["error_count"], 2, "Should find at least 2 errors in the generic error test data") self.assertGreaterEqual(len(result["errors"]), 2, "Should have at least 2 error objects in the errors list") # Check that we found the generic 'Multiple commands produce' errors found_errors = 0 for error in result["errors"]: if (error["type"] == "error" and "Multiple commands produce" in error["message"]): found_errors += 1 self.assertGreaterEqual(found_errors, 2, "Should find at least 2 'Multiple commands produce' errors") if __name__ == '__main__': unittest.main()