diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..723a254 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,24 @@ +[run] +source = gemini_server +omit = + */tests/* + */venv/* + */__pycache__/* + */site-packages/* + +[report] +exclude_lines = + pragma: no cover + def __repr__ + if self.debug: + if settings.DEBUG + raise AssertionError + raise NotImplementedError + if 0: + if __name__ == .__main__.: + if TYPE_CHECKING: + class .*\bProtocol\): + @(abc\.)?abstractmethod + +[html] +directory = htmlcov \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..239bc8f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,80 @@ +name: Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests with pytest + env: + GEMINI_API_KEY: "dummy-key-for-tests" + run: | + pytest tests/ -v --cov=gemini_server --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 black isort mypy + pip install -r requirements.txt + + - name: Lint with flake8 + run: | + # Stop the build if there are Python syntax errors or undefined names + flake8 gemini_server.py --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings + flake8 gemini_server.py --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics + + - name: Check formatting with black + run: | + black --check gemini_server.py + + - name: Check import order with isort + run: | + isort --check-only gemini_server.py + + - name: Type check with mypy + run: | + mypy gemini_server.py --ignore-missing-imports \ No newline at end of file diff --git a/.gitignore b/.gitignore index 04cd3c4..ba48462 100644 --- a/.gitignore +++ b/.gitignore @@ -154,4 +154,8 @@ cython_debug/ # Test outputs test_output/ -*.test.log \ No newline at end of file +*.test.log +.coverage +htmlcov/ +coverage.xml +.pytest_cache/ \ No newline at end of file diff --git a/README.md b/README.md index 857c854..0ba57c4 100644 --- a/README.md +++ b/README.md @@ -231,10 +231,49 @@ Claude: [refines based on feedback] - Token estimation: ~4 characters per token - All file paths should be absolute paths +## ๐Ÿงช Testing + +### Running Tests Locally + +```bash +# Install development dependencies +pip install -r requirements.txt + +# Run tests with coverage +pytest + +# Run tests with verbose output +pytest -v + +# Run specific test file +pytest tests/test_gemini_server.py + +# Generate HTML coverage report +pytest --cov-report=html +open htmlcov/index.html # View coverage report +``` + +### Continuous Integration + +This project uses GitHub Actions for automated testing: +- Tests run on every push and pull request +- Supports Python 3.8 - 3.12 +- Tests on Ubuntu, macOS, and Windows +- Includes linting with flake8, black, isort, and mypy +- Maintains 80%+ code coverage + ## ๐Ÿค Contributing This server is designed specifically for Claude Code users. Contributions that enhance the developer experience are welcome! +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Write tests for your changes +4. Ensure all tests pass (`pytest`) +5. Commit your changes (`git commit -m 'Add amazing feature'`) +6. Push to the branch (`git push origin feature/amazing-feature`) +7. Open a Pull Request + ## ๐Ÿ“„ License MIT License - feel free to customize for your development workflow. \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..11fbe5d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +asyncio_mode = auto +addopts = + -v + --strict-markers + --tb=short + --cov=gemini_server + --cov-report=term-missing + --cov-report=html + --cov-fail-under=80 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7a7a6b2..ba7f9eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,9 @@ mcp>=1.0.0 google-generativeai>=0.8.0 -python-dotenv>=1.0.0 \ No newline at end of file +python-dotenv>=1.0.0 + +# Development dependencies +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +pytest-cov>=4.1.0 +pytest-mock>=3.11.0 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..76714ee --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests for Gemini MCP Server \ No newline at end of file diff --git a/tests/test_gemini_server.py b/tests/test_gemini_server.py new file mode 100644 index 0000000..fb6aaff --- /dev/null +++ b/tests/test_gemini_server.py @@ -0,0 +1,275 @@ +""" +Unit tests for Gemini MCP Server +""" + +import pytest +import json +from unittest.mock import Mock, patch, AsyncMock +from pathlib import Path +import sys + +# Add parent directory to path for imports +sys.path.append(str(Path(__file__).parent.parent)) + +from gemini_server import ( + GeminiChatRequest, + CodeAnalysisRequest, + read_file_content, + prepare_code_context, + handle_list_tools, + handle_call_tool, + DEVELOPER_SYSTEM_PROMPT, + DEFAULT_MODEL +) + + +class TestModels: + """Test request models""" + + def test_gemini_chat_request_defaults(self): + """Test GeminiChatRequest with default values""" + request = GeminiChatRequest(prompt="Test prompt") + assert request.prompt == "Test prompt" + assert request.system_prompt is None + assert request.max_tokens == 8192 + assert request.temperature == 0.5 + assert request.model == DEFAULT_MODEL + + def test_gemini_chat_request_custom(self): + """Test GeminiChatRequest with custom values""" + request = GeminiChatRequest( + prompt="Test prompt", + system_prompt="Custom system", + max_tokens=4096, + temperature=0.8, + model="custom-model" + ) + assert request.system_prompt == "Custom system" + assert request.max_tokens == 4096 + assert request.temperature == 0.8 + assert request.model == "custom-model" + + def test_code_analysis_request_defaults(self): + """Test CodeAnalysisRequest with default values""" + request = CodeAnalysisRequest(question="Analyze this") + assert request.question == "Analyze this" + assert request.files is None + assert request.code is None + assert request.max_tokens == 8192 + assert request.temperature == 0.2 + assert request.model == DEFAULT_MODEL + + +class TestFileOperations: + """Test file reading and context preparation""" + + def test_read_file_content_success(self, tmp_path): + """Test successful file reading""" + test_file = tmp_path / "test.py" + test_file.write_text("def hello():\n return 'world'") + + content = read_file_content(str(test_file)) + assert "=== File:" in content + assert "def hello():" in content + assert "return 'world'" in content + + def test_read_file_content_not_found(self): + """Test reading non-existent file""" + content = read_file_content("/nonexistent/file.py") + assert "Error: File not found" in content + + def test_read_file_content_directory(self, tmp_path): + """Test reading a directory instead of file""" + content = read_file_content(str(tmp_path)) + assert "Error: Not a file" in content + + def test_prepare_code_context_with_files(self, tmp_path): + """Test preparing context from files""" + file1 = tmp_path / "file1.py" + file1.write_text("print('file1')") + file2 = tmp_path / "file2.py" + file2.write_text("print('file2')") + + context = prepare_code_context([str(file1), str(file2)], None) + assert "file1.py" in context + assert "file2.py" in context + assert "print('file1')" in context + assert "print('file2')" in context + + def test_prepare_code_context_with_code(self): + """Test preparing context from direct code""" + code = "def test():\n pass" + context = prepare_code_context(None, code) + assert "=== Direct Code ===" in context + assert code in context + + def test_prepare_code_context_mixed(self, tmp_path): + """Test preparing context from both files and code""" + test_file = tmp_path / "test.py" + test_file.write_text("# From file") + code = "# Direct code" + + context = prepare_code_context([str(test_file)], code) + assert "# From file" in context + assert "# Direct code" in context + + +class TestToolHandlers: + """Test MCP tool handlers""" + + @pytest.mark.asyncio + async def test_handle_list_tools(self): + """Test listing available tools""" + tools = await handle_list_tools() + assert len(tools) == 3 + + tool_names = [tool.name for tool in tools] + assert "chat" in tool_names + assert "analyze_code" in tool_names + assert "list_models" in tool_names + + @pytest.mark.asyncio + async def test_handle_call_tool_unknown(self): + """Test calling unknown tool""" + result = await handle_call_tool("unknown_tool", {}) + assert len(result) == 1 + assert "Unknown tool" in result[0].text + + @pytest.mark.asyncio + @patch('gemini_server.genai.GenerativeModel') + async def test_handle_call_tool_chat_success(self, mock_model): + """Test successful chat tool call""" + # Mock the response + mock_response = Mock() + mock_response.candidates = [Mock()] + mock_response.candidates[0].content.parts = [Mock(text="Test response")] + + mock_instance = Mock() + mock_instance.generate_content.return_value = mock_response + mock_model.return_value = mock_instance + + result = await handle_call_tool("chat", { + "prompt": "Test prompt", + "temperature": 0.5 + }) + + assert len(result) == 1 + assert result[0].text == "Test response" + + # Verify model was called with correct parameters + mock_model.assert_called_once() + call_args = mock_model.call_args[1] + assert call_args['model_name'] == DEFAULT_MODEL + assert call_args['generation_config']['temperature'] == 0.5 + + @pytest.mark.asyncio + @patch('gemini_server.genai.GenerativeModel') + async def test_handle_call_tool_chat_with_developer_prompt(self, mock_model): + """Test chat tool uses developer prompt when no system prompt provided""" + mock_response = Mock() + mock_response.candidates = [Mock()] + mock_response.candidates[0].content.parts = [Mock(text="Response")] + + mock_instance = Mock() + mock_instance.generate_content.return_value = mock_response + mock_model.return_value = mock_instance + + await handle_call_tool("chat", {"prompt": "Test"}) + + # Check that developer prompt was included + call_args = mock_instance.generate_content.call_args[0][0] + assert DEVELOPER_SYSTEM_PROMPT in call_args + + @pytest.mark.asyncio + async def test_handle_call_tool_analyze_code_no_input(self): + """Test analyze_code with no files or code""" + result = await handle_call_tool("analyze_code", { + "question": "Analyze what?" + }) + assert len(result) == 1 + assert "Must provide either 'files' or 'code'" in result[0].text + + @pytest.mark.asyncio + @patch('gemini_server.genai.GenerativeModel') + async def test_handle_call_tool_analyze_code_success(self, mock_model, tmp_path): + """Test successful code analysis""" + # Create test file + test_file = tmp_path / "test.py" + test_file.write_text("def hello(): pass") + + # Mock response + mock_response = Mock() + mock_response.candidates = [Mock()] + mock_response.candidates[0].content.parts = [Mock(text="Analysis result")] + + mock_instance = Mock() + mock_instance.generate_content.return_value = mock_response + mock_model.return_value = mock_instance + + result = await handle_call_tool("analyze_code", { + "files": [str(test_file)], + "question": "Analyze this" + }) + + assert len(result) == 1 + assert result[0].text == "Analysis result" + + @pytest.mark.asyncio + @patch('gemini_server.genai.list_models') + async def test_handle_call_tool_list_models(self, mock_list_models): + """Test listing models""" + # Mock model data + mock_model = Mock() + mock_model.name = "test-model" + mock_model.display_name = "Test Model" + mock_model.description = "A test model" + mock_model.supported_generation_methods = ['generateContent'] + + mock_list_models.return_value = [mock_model] + + result = await handle_call_tool("list_models", {}) + assert len(result) == 1 + + models = json.loads(result[0].text) + assert len(models) == 1 + assert models[0]['name'] == "test-model" + assert models[0]['is_default'] == False + + +class TestErrorHandling: + """Test error handling scenarios""" + + @pytest.mark.asyncio + @patch('gemini_server.genai.GenerativeModel') + async def test_handle_call_tool_chat_api_error(self, mock_model): + """Test handling API errors in chat""" + mock_instance = Mock() + mock_instance.generate_content.side_effect = Exception("API Error") + mock_model.return_value = mock_instance + + result = await handle_call_tool("chat", {"prompt": "Test"}) + assert len(result) == 1 + assert "Error calling Gemini API" in result[0].text + assert "API Error" in result[0].text + + @pytest.mark.asyncio + @patch('gemini_server.genai.GenerativeModel') + async def test_handle_call_tool_chat_blocked_response(self, mock_model): + """Test handling blocked responses""" + mock_response = Mock() + mock_response.candidates = [Mock()] + mock_response.candidates[0].content.parts = [] + mock_response.candidates[0].finish_reason = 2 + + mock_instance = Mock() + mock_instance.generate_content.return_value = mock_response + mock_model.return_value = mock_instance + + result = await handle_call_tool("chat", {"prompt": "Test"}) + assert len(result) == 1 + assert "Response blocked or incomplete" in result[0].text + assert "Finish reason: 2" in result[0].text + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file