soleprint init commit
This commit is contained in:
342
station/tools/tester/core.py
Normal file
342
station/tools/tester/core.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
Core logic for test discovery and execution.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import time
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TestStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
PASSED = "passed"
|
||||
FAILED = "failed"
|
||||
ERROR = "error"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestInfo:
|
||||
"""Information about a discovered test."""
|
||||
id: str
|
||||
name: str
|
||||
module: str
|
||||
class_name: str
|
||||
method_name: str
|
||||
doc: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
"""Result of a single test execution."""
|
||||
test_id: str
|
||||
name: str
|
||||
status: TestStatus
|
||||
duration: float = 0.0
|
||||
error_message: Optional[str] = None
|
||||
traceback: Optional[str] = None
|
||||
artifacts: list[dict] = field(default_factory=list) # List of artifact metadata
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunStatus:
|
||||
"""Status of a test run."""
|
||||
run_id: str
|
||||
status: str # "running", "completed", "failed"
|
||||
total: int = 0
|
||||
completed: int = 0
|
||||
passed: int = 0
|
||||
failed: int = 0
|
||||
errors: int = 0
|
||||
skipped: int = 0
|
||||
results: list[TestResult] = field(default_factory=list)
|
||||
started_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
current_test: Optional[str] = None
|
||||
|
||||
|
||||
# Global storage for run statuses
|
||||
_runs: dict[str, RunStatus] = {}
|
||||
_runs_lock = threading.Lock()
|
||||
|
||||
|
||||
def discover_tests() -> list[TestInfo]:
|
||||
"""Discover all tests in the tests directory."""
|
||||
tests_dir = Path(__file__).parent / "tests"
|
||||
# top_level_dir must be contracts_http's parent (tools/) so that
|
||||
# relative imports like "from ...base" resolve to contracts_http.base
|
||||
top_level = Path(__file__).parent.parent
|
||||
loader = unittest.TestLoader()
|
||||
|
||||
# Discover tests
|
||||
suite = loader.discover(str(tests_dir), pattern="test_*.py", top_level_dir=str(top_level))
|
||||
|
||||
tests = []
|
||||
|
||||
def extract_tests(suite_or_case):
|
||||
if isinstance(suite_or_case, unittest.TestSuite):
|
||||
for item in suite_or_case:
|
||||
extract_tests(item)
|
||||
elif isinstance(suite_or_case, unittest.TestCase):
|
||||
test_method = getattr(suite_or_case, suite_or_case._testMethodName, None)
|
||||
doc = test_method.__doc__ if test_method else None
|
||||
|
||||
# Build module path relative to tests/
|
||||
module_parts = suite_or_case.__class__.__module__.split(".")
|
||||
# Remove 'contracts_http.tests' prefix if present
|
||||
if len(module_parts) > 2 and module_parts[-3] == "tests":
|
||||
module_name = ".".join(module_parts[-2:])
|
||||
else:
|
||||
module_name = suite_or_case.__class__.__module__
|
||||
|
||||
test_id = f"{module_name}.{suite_or_case.__class__.__name__}.{suite_or_case._testMethodName}"
|
||||
|
||||
tests.append(TestInfo(
|
||||
id=test_id,
|
||||
name=suite_or_case._testMethodName,
|
||||
module=module_name,
|
||||
class_name=suite_or_case.__class__.__name__,
|
||||
method_name=suite_or_case._testMethodName,
|
||||
doc=doc.strip() if doc else None,
|
||||
))
|
||||
|
||||
extract_tests(suite)
|
||||
return tests
|
||||
|
||||
|
||||
def get_tests_tree() -> dict:
|
||||
"""Get tests organized as a tree structure for the UI."""
|
||||
tests = discover_tests()
|
||||
tree = {}
|
||||
|
||||
for test in tests:
|
||||
# Parse module to get folder structure
|
||||
parts = test.module.split(".")
|
||||
folder = parts[0] if parts else "root"
|
||||
|
||||
if folder not in tree:
|
||||
tree[folder] = {"modules": {}, "test_count": 0}
|
||||
|
||||
module_name = parts[-1] if len(parts) > 1 else test.module
|
||||
if module_name not in tree[folder]["modules"]:
|
||||
tree[folder]["modules"][module_name] = {"classes": {}, "test_count": 0}
|
||||
|
||||
if test.class_name not in tree[folder]["modules"][module_name]["classes"]:
|
||||
tree[folder]["modules"][module_name]["classes"][test.class_name] = {"tests": [], "test_count": 0}
|
||||
|
||||
tree[folder]["modules"][module_name]["classes"][test.class_name]["tests"].append({
|
||||
"id": test.id,
|
||||
"name": test.method_name,
|
||||
"doc": test.doc,
|
||||
})
|
||||
tree[folder]["modules"][module_name]["classes"][test.class_name]["test_count"] += 1
|
||||
tree[folder]["modules"][module_name]["test_count"] += 1
|
||||
tree[folder]["test_count"] += 1
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
class ResultCollector(unittest.TestResult):
|
||||
"""Custom test result collector."""
|
||||
|
||||
def __init__(self, run_status: RunStatus):
|
||||
super().__init__()
|
||||
self.run_status = run_status
|
||||
self._test_start_times: dict[str, float] = {}
|
||||
|
||||
def _get_test_id(self, test: unittest.TestCase) -> str:
|
||||
module_parts = test.__class__.__module__.split(".")
|
||||
if len(module_parts) > 2 and module_parts[-3] == "tests":
|
||||
module_name = ".".join(module_parts[-2:])
|
||||
else:
|
||||
module_name = test.__class__.__module__
|
||||
return f"{module_name}.{test.__class__.__name__}.{test._testMethodName}"
|
||||
|
||||
def startTest(self, test):
|
||||
super().startTest(test)
|
||||
test_id = self._get_test_id(test)
|
||||
self._test_start_times[test_id] = time.time()
|
||||
with _runs_lock:
|
||||
self.run_status.current_test = test_id
|
||||
|
||||
def stopTest(self, test):
|
||||
super().stopTest(test)
|
||||
with _runs_lock:
|
||||
self.run_status.current_test = None
|
||||
|
||||
def addSuccess(self, test):
|
||||
super().addSuccess(test)
|
||||
test_id = self._get_test_id(test)
|
||||
duration = time.time() - self._test_start_times.get(test_id, time.time())
|
||||
|
||||
result = TestResult(
|
||||
test_id=test_id,
|
||||
name=test._testMethodName,
|
||||
status=TestStatus.PASSED,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
with _runs_lock:
|
||||
self.run_status.results.append(result)
|
||||
self.run_status.completed += 1
|
||||
self.run_status.passed += 1
|
||||
|
||||
def addFailure(self, test, err):
|
||||
super().addFailure(test, err)
|
||||
test_id = self._get_test_id(test)
|
||||
duration = time.time() - self._test_start_times.get(test_id, time.time())
|
||||
|
||||
result = TestResult(
|
||||
test_id=test_id,
|
||||
name=test._testMethodName,
|
||||
status=TestStatus.FAILED,
|
||||
duration=duration,
|
||||
error_message=str(err[1]),
|
||||
traceback="".join(traceback.format_exception(*err)),
|
||||
)
|
||||
|
||||
with _runs_lock:
|
||||
self.run_status.results.append(result)
|
||||
self.run_status.completed += 1
|
||||
self.run_status.failed += 1
|
||||
|
||||
def addError(self, test, err):
|
||||
super().addError(test, err)
|
||||
test_id = self._get_test_id(test)
|
||||
duration = time.time() - self._test_start_times.get(test_id, time.time())
|
||||
|
||||
result = TestResult(
|
||||
test_id=test_id,
|
||||
name=test._testMethodName,
|
||||
status=TestStatus.ERROR,
|
||||
duration=duration,
|
||||
error_message=str(err[1]),
|
||||
traceback="".join(traceback.format_exception(*err)),
|
||||
)
|
||||
|
||||
with _runs_lock:
|
||||
self.run_status.results.append(result)
|
||||
self.run_status.completed += 1
|
||||
self.run_status.errors += 1
|
||||
|
||||
def addSkip(self, test, reason):
|
||||
super().addSkip(test, reason)
|
||||
test_id = self._get_test_id(test)
|
||||
duration = time.time() - self._test_start_times.get(test_id, time.time())
|
||||
|
||||
result = TestResult(
|
||||
test_id=test_id,
|
||||
name=test._testMethodName,
|
||||
status=TestStatus.SKIPPED,
|
||||
duration=duration,
|
||||
error_message=reason,
|
||||
)
|
||||
|
||||
with _runs_lock:
|
||||
self.run_status.results.append(result)
|
||||
self.run_status.completed += 1
|
||||
self.run_status.skipped += 1
|
||||
|
||||
|
||||
def _run_tests_thread(run_id: str, test_ids: Optional[list[str]] = None):
|
||||
"""Run tests in a background thread."""
|
||||
tests_dir = Path(__file__).parent / "tests"
|
||||
top_level = Path(__file__).parent.parent
|
||||
loader = unittest.TestLoader()
|
||||
|
||||
# Discover all tests
|
||||
suite = loader.discover(str(tests_dir), pattern="test_*.py", top_level_dir=str(top_level))
|
||||
|
||||
# Filter to selected tests if specified
|
||||
if test_ids:
|
||||
filtered_suite = unittest.TestSuite()
|
||||
|
||||
def filter_tests(suite_or_case):
|
||||
if isinstance(suite_or_case, unittest.TestSuite):
|
||||
for item in suite_or_case:
|
||||
filter_tests(item)
|
||||
elif isinstance(suite_or_case, unittest.TestCase):
|
||||
module_parts = suite_or_case.__class__.__module__.split(".")
|
||||
if len(module_parts) > 2 and module_parts[-3] == "tests":
|
||||
module_name = ".".join(module_parts[-2:])
|
||||
else:
|
||||
module_name = suite_or_case.__class__.__module__
|
||||
test_id = f"{module_name}.{suite_or_case.__class__.__name__}.{suite_or_case._testMethodName}"
|
||||
|
||||
# Check if this test matches any of the requested IDs
|
||||
for requested_id in test_ids:
|
||||
if test_id == requested_id or test_id.startswith(requested_id + ".") or requested_id in test_id:
|
||||
filtered_suite.addTest(suite_or_case)
|
||||
break
|
||||
|
||||
filter_tests(suite)
|
||||
suite = filtered_suite
|
||||
|
||||
# Count total tests
|
||||
total = suite.countTestCases()
|
||||
|
||||
with _runs_lock:
|
||||
_runs[run_id].total = total
|
||||
_runs[run_id].started_at = time.time()
|
||||
|
||||
# Run tests with our collector
|
||||
collector = ResultCollector(_runs[run_id])
|
||||
|
||||
try:
|
||||
suite.run(collector)
|
||||
except Exception as e:
|
||||
with _runs_lock:
|
||||
_runs[run_id].status = "failed"
|
||||
|
||||
with _runs_lock:
|
||||
_runs[run_id].status = "completed"
|
||||
_runs[run_id].finished_at = time.time()
|
||||
|
||||
|
||||
def start_test_run(test_ids: Optional[list[str]] = None) -> str:
|
||||
"""Start a test run in the background. Returns run_id."""
|
||||
run_id = str(uuid.uuid4())[:8]
|
||||
|
||||
run_status = RunStatus(
|
||||
run_id=run_id,
|
||||
status="running",
|
||||
)
|
||||
|
||||
with _runs_lock:
|
||||
_runs[run_id] = run_status
|
||||
|
||||
# Start background thread
|
||||
thread = threading.Thread(target=_run_tests_thread, args=(run_id, test_ids))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return run_id
|
||||
|
||||
|
||||
def get_run_status(run_id: str) -> Optional[RunStatus]:
|
||||
"""Get the status of a test run."""
|
||||
with _runs_lock:
|
||||
return _runs.get(run_id)
|
||||
|
||||
|
||||
def list_runs() -> list[dict]:
|
||||
"""List all test runs."""
|
||||
with _runs_lock:
|
||||
return [
|
||||
{
|
||||
"run_id": run.run_id,
|
||||
"status": run.status,
|
||||
"total": run.total,
|
||||
"completed": run.completed,
|
||||
"passed": run.passed,
|
||||
"failed": run.failed,
|
||||
}
|
||||
for run in _runs.values()
|
||||
]
|
||||
Reference in New Issue
Block a user