97 findings to zero. Most were mechanical — 52 unsorted import blocks, 10 unsorted __all__, assorted pyupgrade and simplify hints. Two were not, and both were visible only because the lint made me look. `webber version` did not exist. src/cli/commands/version.py defines show_version(), main.py imported it, and the registration line was never written — the CLI exposed chat and explore only. The import carried `# noqa: F401`, which is what kept the omission quiet: someone marked the symptom as intentional instead of asking why it was unused. show_version is not redundant with the --version flag; it prints the resolved Ollama URL, model and debug state, which is the form worth having when something is misconfigured. Registered, and the suppression dropped because the import is now genuinely used. test_spawn_explore_agent asserted nothing. It built a mock RunContext, patched get_agent, and stopped at the comment "For now, verify the explore agent would be called correctly". It had been counted as a passing test. An AST sweep of all 238 test functions found it was the only one, which is worth knowing — the problem was contained, not systemic. It is now skipped with a reason, so it reports as unfinished rather than as passing. Reducing it rather than deleting its imports was the point: tidying the imports would have made a hollow test look clean. Two findings were false positives, and both are recorded rather than silently worked around: B023 flagged run_agent closing over full_prompt and ctx. Traced: agent_task is awaited at line 326 before `continue` reaches the next iteration, so neither name can be rebound while the closure is pending, and the exception path cancels and awaits too. Not a bug. Bound as defaults anyway, because that stays true if the await ever moves. I had called it a live bug before tracing it, which is the mistake Rule 5 exists for. RUF012 flagged `rules: list[ApprovalRule] = []` on ApprovalRuleSet. Its suggested fix — annotate ClassVar — would remove the field from the model. ApprovalRuleSet is a pydantic model and pydantic deep-copies defaults per instance; verified by constructing two and confirming their lists are distinct objects. Suppressed with that evidence in the comment. Ruff cannot see the pydantic base because BaseSchema is a local subclass of BaseModel. Also moved a stray `from src.shared.logging import ...` that had drifted below a function definition, and merged a nested if in the ollama provider. 215 passed, 23 skipped, unchanged except for the new skip. `webber version` exercised end to end. mypy is NOT addressed here and the gate still fails on it — 55 errors in 14 files, 35 of them no-any-return from pydantic_ai's untyped returns. That was hidden behind ruff, because the gate stops at the first failing stage. Co-Authored-By: Claude <noreply@anthropic.com>
343 lines
8.7 KiB
Python
343 lines
8.7 KiB
Python
"""
|
|
Pytest configuration and fixtures.
|
|
|
|
Test categories:
|
|
- Unit tests: Run by default, no external dependencies
|
|
- Integration tests: Require Ollama, run with --run-integration
|
|
- E2E tests: Require running API server, run with --run-e2e
|
|
|
|
Usage:
|
|
pytest tests/ # Run unit tests only
|
|
pytest tests/ --run-integration # Include integration tests
|
|
pytest tests/ --run-e2e # Include E2E tests
|
|
pytest tests/ --run-integration --run-e2e # Run all tests
|
|
"""
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from src.main import app
|
|
|
|
# =============================================================================
|
|
# Command Line Options
|
|
# =============================================================================
|
|
|
|
def pytest_addoption(parser):
|
|
"""Add custom command line options."""
|
|
parser.addoption(
|
|
"--run-integration",
|
|
action="store_true",
|
|
default=False,
|
|
help="Run integration tests (require Ollama to be running)",
|
|
)
|
|
parser.addoption(
|
|
"--run-e2e",
|
|
action="store_true",
|
|
default=False,
|
|
help="Run E2E tests (require API server to be running)",
|
|
)
|
|
parser.addoption(
|
|
"--ollama-url",
|
|
action="store",
|
|
default="http://192.168.86.149:11434",
|
|
help="Ollama API URL for integration tests",
|
|
)
|
|
parser.addoption(
|
|
"--api-url",
|
|
action="store",
|
|
default="http://localhost:8095",
|
|
help="Webber API URL for E2E tests",
|
|
)
|
|
|
|
|
|
def pytest_collection_modifyitems(config, items):
|
|
"""Skip integration/e2e tests unless explicitly requested."""
|
|
skip_integration = pytest.mark.skip(reason="need --run-integration option to run")
|
|
skip_e2e = pytest.mark.skip(reason="need --run-e2e option to run")
|
|
|
|
for item in items:
|
|
if "integration" in item.keywords and not config.getoption("--run-integration"):
|
|
item.add_marker(skip_integration)
|
|
if "e2e" in item.keywords and not config.getoption("--run-e2e"):
|
|
item.add_marker(skip_e2e)
|
|
|
|
|
|
# =============================================================================
|
|
# Basic Fixtures
|
|
# =============================================================================
|
|
|
|
@pytest.fixture
|
|
def anyio_backend():
|
|
"""Use asyncio for async tests."""
|
|
return "asyncio"
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
"""Async HTTP client for testing (no auth)."""
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://test"
|
|
) as ac:
|
|
yield ac
|
|
|
|
|
|
@pytest.fixture
|
|
async def auth_client():
|
|
"""Async HTTP client with API key for authenticated requests."""
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://test",
|
|
headers={"X-API-Key": "test-api-key"}
|
|
) as ac:
|
|
yield ac
|
|
|
|
|
|
# =============================================================================
|
|
# Integration Test Fixtures
|
|
# =============================================================================
|
|
|
|
@pytest.fixture
|
|
def ollama_url(request):
|
|
"""Get Ollama URL from command line or environment."""
|
|
return request.config.getoption("--ollama-url") or os.environ.get(
|
|
"OLLAMA_URL", "http://192.168.86.149:11434"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def api_url(request):
|
|
"""Get API URL from command line or environment."""
|
|
return request.config.getoption("--api-url") or os.environ.get(
|
|
"WEBBER_API_URL", "http://localhost:8095"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_project():
|
|
"""Create a sample Python project for testing."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
project = Path(tmpdir)
|
|
|
|
# Create a realistic project structure
|
|
(project / "src").mkdir()
|
|
(project / "tests").mkdir()
|
|
|
|
# Main application file
|
|
(project / "src" / "__init__.py").write_text("")
|
|
(project / "src" / "main.py").write_text('''"""Main application module."""
|
|
|
|
def greet(name: str) -> str:
|
|
"""Greet someone by name.
|
|
|
|
Args:
|
|
name: The name to greet
|
|
|
|
Returns:
|
|
A greeting string
|
|
"""
|
|
return f"Hello, {name}!"
|
|
|
|
|
|
def add(a: int, b: int) -> int:
|
|
"""Add two numbers.
|
|
|
|
Args:
|
|
a: First number
|
|
b: Second number
|
|
|
|
Returns:
|
|
Sum of a and b
|
|
"""
|
|
return a + b
|
|
|
|
|
|
def divide(a: float, b: float) -> float:
|
|
"""Divide two numbers.
|
|
|
|
Args:
|
|
a: Dividend
|
|
b: Divisor
|
|
|
|
Returns:
|
|
Result of a / b
|
|
|
|
Raises:
|
|
ValueError: If b is zero
|
|
"""
|
|
if b == 0:
|
|
raise ValueError("Cannot divide by zero")
|
|
return a / b
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(greet("World"))
|
|
''')
|
|
|
|
# Utility module
|
|
(project / "src" / "utils.py").write_text('''"""Utility functions."""
|
|
|
|
def is_even(n: int) -> bool:
|
|
"""Check if a number is even."""
|
|
return n % 2 == 0
|
|
|
|
|
|
def is_prime(n: int) -> bool:
|
|
"""Check if a number is prime."""
|
|
if n < 2:
|
|
return False
|
|
for i in range(2, int(n ** 0.5) + 1):
|
|
if n % i == 0:
|
|
return False
|
|
return True
|
|
|
|
|
|
def factorial(n: int) -> int:
|
|
"""Calculate factorial recursively."""
|
|
if n <= 1:
|
|
return 1
|
|
return n * factorial(n - 1)
|
|
|
|
|
|
def fibonacci(n: int) -> list[int]:
|
|
"""Generate Fibonacci sequence up to n terms."""
|
|
if n <= 0:
|
|
return []
|
|
if n == 1:
|
|
return [0]
|
|
|
|
fib = [0, 1]
|
|
for _ in range(2, n):
|
|
fib.append(fib[-1] + fib[-2])
|
|
return fib
|
|
''')
|
|
|
|
# Test file
|
|
(project / "tests" / "__init__.py").write_text("")
|
|
(project / "tests" / "test_main.py").write_text('''"""Tests for main module."""
|
|
import pytest
|
|
from src.main import greet, add, divide
|
|
|
|
|
|
def test_greet():
|
|
assert greet("World") == "Hello, World!"
|
|
|
|
|
|
def test_add():
|
|
assert add(2, 3) == 5
|
|
|
|
|
|
def test_divide():
|
|
assert divide(10, 2) == 5.0
|
|
|
|
|
|
def test_divide_by_zero():
|
|
with pytest.raises(ValueError):
|
|
divide(1, 0)
|
|
''')
|
|
|
|
# README
|
|
(project / "README.md").write_text('''# Sample Project
|
|
|
|
A simple Python project for testing Webber's code exploration.
|
|
|
|
## Features
|
|
|
|
- Greeting functionality
|
|
- Math utilities
|
|
- Comprehensive test suite
|
|
|
|
## Usage
|
|
|
|
```python
|
|
from src.main import greet, add
|
|
print(greet("World"))
|
|
print(add(2, 3))
|
|
```
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
pytest tests/
|
|
```
|
|
''')
|
|
|
|
# Configuration files
|
|
(project / "pyproject.toml").write_text('''[project]
|
|
name = "sample-project"
|
|
version = "0.1.0"
|
|
|
|
[tool.pytest.ini_options]
|
|
testpaths = ["tests"]
|
|
''')
|
|
|
|
yield project
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_project_with_bug():
|
|
"""Create a sample project with intentional bugs for testing."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
project = Path(tmpdir)
|
|
|
|
(project / "buggy.py").write_text('''"""Module with intentional bugs."""
|
|
|
|
def divide_numbers(a, b):
|
|
"""Divide two numbers - BUG: no zero check."""
|
|
return a / b # BUG: ZeroDivisionError if b is 0
|
|
|
|
|
|
def get_item(lst, index):
|
|
"""Get item from list - BUG: no bounds check."""
|
|
return lst[index] # BUG: IndexError if out of bounds
|
|
|
|
|
|
def parse_int(s):
|
|
"""Parse string to int - BUG: no error handling."""
|
|
return int(s) # BUG: ValueError if not a valid int
|
|
|
|
|
|
# TODO: Fix the division bug
|
|
# FIXME: Add bounds checking to get_item
|
|
''')
|
|
|
|
yield project
|
|
|
|
|
|
# =============================================================================
|
|
# E2E Test Fixtures
|
|
# =============================================================================
|
|
|
|
@pytest.fixture
|
|
async def live_client(api_url):
|
|
"""HTTP client for E2E tests against running server."""
|
|
async with AsyncClient(base_url=api_url, timeout=30.0) as client:
|
|
yield client
|
|
|
|
|
|
# =============================================================================
|
|
# Helper Functions
|
|
# =============================================================================
|
|
|
|
def assert_contains_any(text: str, substrings: list[str], case_sensitive: bool = False) -> bool:
|
|
"""Assert that text contains at least one of the substrings."""
|
|
check_text = text if case_sensitive else text.lower()
|
|
check_subs = substrings if case_sensitive else [s.lower() for s in substrings]
|
|
|
|
found = [s for s in check_subs if s in check_text]
|
|
assert found, f"Expected text to contain one of {substrings}, but none found in: {text[:200]}..."
|
|
return True
|
|
|
|
|
|
def assert_contains_all(text: str, substrings: list[str], case_sensitive: bool = False) -> bool:
|
|
"""Assert that text contains all of the substrings."""
|
|
check_text = text if case_sensitive else text.lower()
|
|
check_subs = substrings if case_sensitive else [s.lower() for s in substrings]
|
|
|
|
missing = [s for s in check_subs if s not in check_text]
|
|
assert not missing, f"Expected text to contain all of {substrings}, missing: {missing}"
|
|
return True
|