Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: Python Unit Tests

on:
pull_request:
paths:
- 'libs/python/**'
- '.github/workflows/python-tests.yml'
push:
branches:
- main
paths:
- 'libs/python/**'
- '.github/workflows/python-tests.yml'
workflow_dispatch: # Allow manual trigger

jobs:
test:
name: Test ${{ matrix.package }}
runs-on: ubuntu-latest

strategy:
fail-fast: false # Test all packages even if one fails
matrix:
package:
- core
- agent
- computer
- computer-server
- mcp-server
- pylume
- som

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install uv
run: |
pip install uv

- name: Install package and dependencies
run: |
cd libs/python/${{ matrix.package }}
# Install the package in editable mode with dev dependencies
if [ -f pyproject.toml ]; then
uv pip install --system -e .
# Install test dependencies
uv pip install --system pytest pytest-asyncio pytest-mock pytest-cov
fi
shell: bash

- name: Run tests
run: |
cd libs/python/${{ matrix.package }}
if [ -d tests ]; then
python -m pytest tests/ -v --tb=short --cov --cov-report=term --cov-report=xml
else
echo "No tests directory found, skipping tests"
fi
shell: bash
env:
CUA_TELEMETRY_DISABLED: "1" # Disable telemetry during tests

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
if: always()
with:
file: ./libs/python/${{ matrix.package }}/coverage.xml
flags: ${{ matrix.package }}
name: codecov-${{ matrix.package }}
fail_ci_if_error: false
continue-on-error: true

summary:
name: Test Summary
runs-on: ubuntu-latest
needs: test
if: always()

steps:
- name: Check test results
run: |
if [ "${{ needs.test.result }}" == "failure" ]; then
echo "❌ Some tests failed. Please check the logs above."
exit 1
else
echo "✅ All tests passed!"
fi
104 changes: 104 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Testing Guide for CUA

Quick guide to running tests and understanding the test architecture.

## 🚀 Quick Start

```bash
# Install dependencies
pip install pytest pytest-asyncio pytest-mock pytest-cov

# Install package
cd libs/python/core
pip install -e .

# Run tests
export CUA_TELEMETRY_DISABLED=1 # or $env:CUA_TELEMETRY_DISABLED="1" on Windows
pytest tests/ -v
```

## 🧪 Running Tests

```bash
# All packages
pytest libs/python/*/tests/ -v

# Specific package
cd libs/python/core && pytest tests/ -v

# With coverage
pytest tests/ --cov --cov-report=html

# Specific test
pytest tests/test_telemetry.py::TestTelemetryEnabled::test_telemetry_enabled_by_default -v
```

## 🏗️ Test Architecture

**Principles**: SRP (Single Responsibility) + Vertical Slices + Testability

```
libs/python/
├── core/tests/ # Tests ONLY core
├── agent/tests/ # Tests ONLY agent
└── computer/tests/ # Tests ONLY computer
```

Each test file = ONE feature. Each test class = ONE concern.

## ➕ Adding New Tests

1. Create `test_*.py` in the appropriate package's `tests/` directory
2. Follow the pattern:

```python
"""Unit tests for my_feature."""
import pytest
from unittest.mock import patch

class TestMyFeature:
"""Test MyFeature class."""

def test_initialization(self):
"""Test that feature initializes."""
from my_package import MyFeature
feature = MyFeature()
assert feature is not None
```

3. Mock external dependencies:

```python
@pytest.fixture
def mock_api():
with patch("my_package.api_client") as mock:
yield mock
```

## 🔄 CI/CD

Tests run automatically on every PR via GitHub Actions (`.github/workflows/python-tests.yml`):
- Matrix strategy: each package tested separately
- Python 3.12
- ~2 minute runtime

## 🐛 Troubleshooting

**ModuleNotFoundError**: Run `pip install -e .` in package directory

**Tests fail in CI but pass locally**: Set `CUA_TELEMETRY_DISABLED=1`

**Async tests error**: Install `pytest-asyncio` and use `@pytest.mark.asyncio`

**Mock not working**: Patch at usage location, not definition:
```python
# ✅ Right
@patch("my_package.module.external_function")

# ❌ Wrong
@patch("external_library.function")
```

---

**Questions?** Check existing tests for examples or open an issue.
84 changes: 84 additions & 0 deletions libs/python/agent/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Pytest configuration and shared fixtures for agent package tests.

This file contains shared fixtures and configuration for all agent tests.
Following SRP: This file ONLY handles test setup/teardown.
"""

import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock


@pytest.fixture
def mock_litellm():
"""Mock liteLLM completion calls.

Use this fixture to avoid making real LLM API calls during tests.
Returns a mock that simulates LLM responses.
"""
with patch("litellm.acompletion") as mock_completion:
async def mock_response(*args, **kwargs):
"""Simulate a typical LLM response."""
return {
"id": "chatcmpl-test123",
"object": "chat.completion",
"created": 1234567890,
"model": kwargs.get("model", "anthropic/claude-3-5-sonnet-20241022"),
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "This is a mocked response for testing.",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
},
}

mock_completion.side_effect = mock_response
yield mock_completion


@pytest.fixture
def mock_computer():
"""Mock Computer interface for agent tests.

Use this fixture to test agent logic without requiring a real Computer instance.
"""
computer = AsyncMock()
computer.interface = AsyncMock()
computer.interface.screenshot = AsyncMock(return_value=b"fake_screenshot_data")
computer.interface.left_click = AsyncMock()
computer.interface.type = AsyncMock()
computer.interface.key = AsyncMock()

# Mock context manager
computer.__aenter__ = AsyncMock(return_value=computer)
computer.__aexit__ = AsyncMock()

return computer


@pytest.fixture
def disable_telemetry(monkeypatch):
"""Disable telemetry for tests.

Use this fixture to ensure no telemetry is sent during tests.
"""
monkeypatch.setenv("CUA_TELEMETRY_DISABLED", "1")


@pytest.fixture
def sample_messages():
"""Provide sample messages for testing.

Returns a list of messages in the expected format.
"""
return [
{"role": "user", "content": "Take a screenshot and tell me what you see"}
]
Loading