feat: run tests in background with progress polling
Improve the testing dashboard to run pytest in the background: - Add background task execution using FastAPI's BackgroundTasks - Create test_runner_tasks.py following existing background task pattern - API now returns immediately after starting the test run - Frontend polls for status every 2 seconds until completion - Show running indicator with elapsed time counter - Resume polling if user navigates away and returns while tests running - Tests continue running even if user closes the page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,13 +3,14 @@ Test Runner API Endpoints
|
|||||||
RESTful API for running pytest and viewing test results
|
RESTful API for running pytest and viewing test results
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.test_runner_service import test_runner_service
|
from app.services.test_runner_service import test_runner_service
|
||||||
|
from app.tasks.test_runner_tasks import execute_test_run
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -96,27 +97,37 @@ class TestDashboardStatsResponse(BaseModel):
|
|||||||
|
|
||||||
@router.post("/run", response_model=TestRunResponse)
|
@router.post("/run", response_model=TestRunResponse)
|
||||||
async def run_tests(
|
async def run_tests(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
request: RunTestsRequest | None = None,
|
request: RunTestsRequest | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_admin_api),
|
current_user: User = Depends(get_current_admin_api),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Run pytest and store results
|
Start a pytest run in the background
|
||||||
|
|
||||||
Requires admin authentication. Runs pytest on the specified path
|
Requires admin authentication. Creates a test run record and starts
|
||||||
and stores results in the database.
|
pytest execution in the background. Returns immediately with the run ID.
|
||||||
|
Poll GET /runs/{run_id} to check status.
|
||||||
"""
|
"""
|
||||||
test_path = request.test_path if request else "tests"
|
test_path = request.test_path if request else "tests"
|
||||||
extra_args = request.extra_args if request else None
|
extra_args = request.extra_args if request else None
|
||||||
|
|
||||||
run = test_runner_service.run_tests(
|
# Create the test run record
|
||||||
|
run = test_runner_service.create_test_run(
|
||||||
db,
|
db,
|
||||||
test_path=test_path,
|
test_path=test_path,
|
||||||
triggered_by=f"manual:{current_user.username}",
|
triggered_by=f"manual:{current_user.username}",
|
||||||
extra_args=extra_args,
|
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Start background execution
|
||||||
|
background_tasks.add_task(
|
||||||
|
execute_test_run,
|
||||||
|
run.id,
|
||||||
|
test_path,
|
||||||
|
extra_args,
|
||||||
|
)
|
||||||
|
|
||||||
return TestRunResponse(
|
return TestRunResponse(
|
||||||
id=run.id,
|
id=run.id,
|
||||||
timestamp=run.timestamp.isoformat(),
|
timestamp=run.timestamp.isoformat(),
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ class TestRunnerService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.project_root = Path(__file__).parent.parent.parent
|
self.project_root = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
def create_test_run(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
test_path: str = "tests",
|
||||||
|
triggered_by: str = "manual",
|
||||||
|
) -> TestRun:
|
||||||
|
"""Create a test run record without executing tests"""
|
||||||
|
test_run = TestRun(
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
test_path=test_path,
|
||||||
|
status="running",
|
||||||
|
git_commit_hash=self._get_git_commit(),
|
||||||
|
git_branch=self._get_git_branch(),
|
||||||
|
)
|
||||||
|
db.add(test_run)
|
||||||
|
db.flush()
|
||||||
|
return test_run
|
||||||
|
|
||||||
def run_tests(
|
def run_tests(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -33,7 +52,7 @@ class TestRunnerService:
|
|||||||
extra_args: list[str] | None = None,
|
extra_args: list[str] | None = None,
|
||||||
) -> TestRun:
|
) -> TestRun:
|
||||||
"""
|
"""
|
||||||
Run pytest and store results in database
|
Run pytest synchronously and store results in database
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
@@ -44,21 +63,19 @@ class TestRunnerService:
|
|||||||
Returns:
|
Returns:
|
||||||
TestRun object with results
|
TestRun object with results
|
||||||
"""
|
"""
|
||||||
# Create test run record
|
test_run = self.create_test_run(db, test_path, triggered_by)
|
||||||
test_run = TestRun(
|
self._execute_tests(db, test_run, test_path, extra_args)
|
||||||
timestamp=datetime.now(UTC),
|
return test_run
|
||||||
triggered_by=triggered_by,
|
|
||||||
test_path=test_path,
|
|
||||||
status="running",
|
|
||||||
)
|
|
||||||
db.add(test_run)
|
|
||||||
db.flush() # Get the ID
|
|
||||||
|
|
||||||
|
def _execute_tests(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
test_run: TestRun,
|
||||||
|
test_path: str,
|
||||||
|
extra_args: list[str] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Execute pytest and update the test run record"""
|
||||||
try:
|
try:
|
||||||
# Get git info
|
|
||||||
test_run.git_commit_hash = self._get_git_commit()
|
|
||||||
test_run.git_branch = self._get_git_branch()
|
|
||||||
|
|
||||||
# Build pytest command with JSON output
|
# Build pytest command with JSON output
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||||
json_report_path = f.name
|
json_report_path = f.name
|
||||||
@@ -66,7 +83,7 @@ class TestRunnerService:
|
|||||||
pytest_args = [
|
pytest_args = [
|
||||||
"python", "-m", "pytest",
|
"python", "-m", "pytest",
|
||||||
test_path,
|
test_path,
|
||||||
f"--json-report",
|
"--json-report",
|
||||||
f"--json-report-file={json_report_path}",
|
f"--json-report-file={json_report_path}",
|
||||||
"-v",
|
"-v",
|
||||||
"--tb=short",
|
"--tb=short",
|
||||||
@@ -120,8 +137,6 @@ class TestRunnerService:
|
|||||||
test_run.status = "error"
|
test_run.status = "error"
|
||||||
logger.error(f"Error running tests: {e}")
|
logger.error(f"Error running tests: {e}")
|
||||||
|
|
||||||
return test_run
|
|
||||||
|
|
||||||
def _process_json_report(self, db: Session, test_run: TestRun, report: dict):
|
def _process_json_report(self, db: Session, test_run: TestRun, report: dict):
|
||||||
"""Process pytest-json-report output"""
|
"""Process pytest-json-report output"""
|
||||||
summary = report.get("summary", {})
|
summary = report.get("summary", {})
|
||||||
|
|||||||
62
app/tasks/test_runner_tasks.py
Normal file
62
app/tasks/test_runner_tasks.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# app/tasks/test_runner_tasks.py
|
||||||
|
"""Background tasks for test runner."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.services.test_runner_service import test_runner_service
|
||||||
|
from models.database.test_run import TestRun
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_test_run(
|
||||||
|
run_id: int,
|
||||||
|
test_path: str = "tests",
|
||||||
|
extra_args: list[str] | None = None,
|
||||||
|
):
|
||||||
|
"""Background task to execute pytest tests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run_id: ID of the TestRun record
|
||||||
|
test_path: Path to tests (relative to project root)
|
||||||
|
extra_args: Additional pytest arguments
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
test_run = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the test run record
|
||||||
|
test_run = db.query(TestRun).filter(TestRun.id == run_id).first()
|
||||||
|
if not test_run:
|
||||||
|
logger.error(f"Test run {run_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Starting test execution: Run {run_id}, Path: {test_path}")
|
||||||
|
|
||||||
|
# Execute the tests
|
||||||
|
test_runner_service._execute_tests(db, test_run, test_path, extra_args)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Test run {run_id} completed: "
|
||||||
|
f"status={test_run.status}, passed={test_run.passed}, "
|
||||||
|
f"failed={test_run.failed}, duration={test_run.duration_seconds:.1f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Test run {run_id} failed: {e}", exc_info=True)
|
||||||
|
if test_run is not None:
|
||||||
|
try:
|
||||||
|
test_run.status = "error"
|
||||||
|
db.commit()
|
||||||
|
except Exception as commit_error:
|
||||||
|
logger.error(f"Failed to update test run status: {commit_error}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
if hasattr(db, "close") and callable(db.close):
|
||||||
|
try:
|
||||||
|
db.close()
|
||||||
|
except Exception as close_error:
|
||||||
|
logger.error(f"Error closing database session: {close_error}")
|
||||||
@@ -23,6 +23,33 @@
|
|||||||
|
|
||||||
{{ alert_dynamic(type='success', message_var='successMessage', show_condition='successMessage') }}
|
{{ alert_dynamic(type='success', message_var='successMessage', show_condition='successMessage') }}
|
||||||
|
|
||||||
|
<!-- Running Indicator -->
|
||||||
|
<div x-show="running" x-cloak class="mb-6">
|
||||||
|
<div class="p-4 bg-purple-100 dark:bg-purple-900 rounded-lg shadow-xs">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="animate-spin h-5 w-5 text-purple-600 dark:text-purple-400 mr-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-purple-800 dark:text-purple-200">Running tests...</p>
|
||||||
|
<p class="text-sm text-purple-600 dark:text-purple-300">Tests are executing in the background. You can leave this page and come back.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-2xl font-bold text-purple-700 dark:text-purple-300" x-text="formatDuration(elapsedTime)">0s</p>
|
||||||
|
<p class="text-xs text-purple-600 dark:text-purple-400">elapsed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="w-full bg-purple-200 dark:bg-purple-800 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div class="bg-purple-600 dark:bg-purple-400 h-1.5 rounded-full animate-pulse" style="width: 100%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Dashboard Content -->
|
<!-- Dashboard Content -->
|
||||||
<div x-show="!loading && !error">
|
<div x-show="!loading && !error">
|
||||||
<!-- Stats Cards Row 1 - Main Metrics -->
|
<!-- Stats Cards Row 1 - Main Metrics -->
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ function testingDashboard() {
|
|||||||
collecting: false,
|
collecting: false,
|
||||||
error: null,
|
error: null,
|
||||||
successMessage: null,
|
successMessage: null,
|
||||||
|
activeRunId: null,
|
||||||
|
pollInterval: null,
|
||||||
|
elapsedTime: 0,
|
||||||
|
elapsedTimer: null,
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
stats: {
|
stats: {
|
||||||
@@ -46,6 +50,30 @@ function testingDashboard() {
|
|||||||
testingDashboardLog.info('Initializing testing dashboard');
|
testingDashboardLog.info('Initializing testing dashboard');
|
||||||
await this.loadStats();
|
await this.loadStats();
|
||||||
await this.loadRuns();
|
await this.loadRuns();
|
||||||
|
// Check if there's a running test and resume polling
|
||||||
|
await this.checkForRunningTests();
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkForRunningTests() {
|
||||||
|
// Check if there's already a test running
|
||||||
|
const runningRun = this.runs.find(r => r.status === 'running');
|
||||||
|
if (runningRun) {
|
||||||
|
testingDashboardLog.info('Found running test:', runningRun.id);
|
||||||
|
this.running = true;
|
||||||
|
this.activeRunId = runningRun.id;
|
||||||
|
|
||||||
|
// Calculate elapsed time from when the run started
|
||||||
|
const startTime = new Date(runningRun.timestamp);
|
||||||
|
this.elapsedTime = Math.floor((Date.now() - startTime.getTime()) / 1000);
|
||||||
|
|
||||||
|
// Start elapsed time counter
|
||||||
|
this.elapsedTimer = setInterval(() => {
|
||||||
|
this.elapsedTime++;
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Start polling for status
|
||||||
|
this.pollInterval = setInterval(() => this.pollRunStatus(), 2000);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadStats() {
|
async loadStats() {
|
||||||
@@ -84,45 +112,89 @@ function testingDashboard() {
|
|||||||
this.running = true;
|
this.running = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
this.successMessage = null;
|
this.successMessage = null;
|
||||||
|
this.elapsedTime = 0;
|
||||||
|
|
||||||
testingDashboardLog.info('Running tests:', testPath);
|
testingDashboardLog.info('Starting tests:', testPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Start the test run (returns immediately)
|
||||||
const result = await apiClient.post('/admin/tests/run', {
|
const result = await apiClient.post('/admin/tests/run', {
|
||||||
test_path: testPath
|
test_path: testPath
|
||||||
});
|
});
|
||||||
|
|
||||||
testingDashboardLog.info('Test run completed:', result);
|
testingDashboardLog.info('Test run started:', result);
|
||||||
|
this.activeRunId = result.id;
|
||||||
|
|
||||||
// Format success message
|
// Start elapsed time counter
|
||||||
const status = result.status === 'passed' ? 'All tests passed!' : 'Tests completed with failures.';
|
this.elapsedTimer = setInterval(() => {
|
||||||
this.successMessage = `${status} ${result.passed}/${result.total_tests} passed (${result.pass_rate.toFixed(1)}%) in ${this.formatDuration(result.duration_seconds)}`;
|
this.elapsedTime++;
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
// Reload stats and runs
|
// Start polling for status
|
||||||
await this.loadStats();
|
this.pollInterval = setInterval(() => this.pollRunStatus(), 2000);
|
||||||
await this.loadRuns();
|
|
||||||
|
|
||||||
// Show toast notification
|
Utils.showToast('Test run started...', 'info');
|
||||||
Utils.showToast(this.successMessage, result.status === 'passed' ? 'success' : 'warning');
|
|
||||||
|
|
||||||
// Clear success message after 10 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
this.successMessage = null;
|
|
||||||
}, 10000);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
testingDashboardLog.error('Failed to run tests:', err);
|
testingDashboardLog.error('Failed to start tests:', err);
|
||||||
this.error = err.message;
|
this.error = err.message;
|
||||||
Utils.showToast('Failed to run tests: ' + err.message, 'error');
|
this.running = false;
|
||||||
|
Utils.showToast('Failed to start tests: ' + err.message, 'error');
|
||||||
|
|
||||||
// Redirect to login if unauthorized
|
// Redirect to login if unauthorized
|
||||||
if (err.message.includes('Unauthorized')) {
|
if (err.message.includes('Unauthorized')) {
|
||||||
window.location.href = '/admin/login';
|
window.location.href = '/admin/login';
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
this.running = false;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async pollRunStatus() {
|
||||||
|
if (!this.activeRunId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const run = await apiClient.get(`/admin/tests/runs/${this.activeRunId}`);
|
||||||
|
|
||||||
|
if (run.status !== 'running') {
|
||||||
|
// Test run completed
|
||||||
|
this.stopPolling();
|
||||||
|
|
||||||
|
testingDashboardLog.info('Test run completed:', run);
|
||||||
|
|
||||||
|
// Format success message
|
||||||
|
const status = run.status === 'passed' ? 'All tests passed!' : 'Tests completed with failures.';
|
||||||
|
this.successMessage = `${status} ${run.passed}/${run.total_tests} passed (${run.pass_rate.toFixed(1)}%) in ${this.formatDuration(run.duration_seconds)}`;
|
||||||
|
|
||||||
|
// Reload stats and runs
|
||||||
|
await this.loadStats();
|
||||||
|
await this.loadRuns();
|
||||||
|
|
||||||
|
// Show toast notification
|
||||||
|
Utils.showToast(this.successMessage, run.status === 'passed' ? 'success' : 'warning');
|
||||||
|
|
||||||
|
// Clear success message after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.successMessage = null;
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
testingDashboardLog.error('Failed to poll run status:', err);
|
||||||
|
// Don't stop polling on error, might be transient
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPolling() {
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval);
|
||||||
|
this.pollInterval = null;
|
||||||
|
}
|
||||||
|
if (this.elapsedTimer) {
|
||||||
|
clearInterval(this.elapsedTimer);
|
||||||
|
this.elapsedTimer = null;
|
||||||
|
}
|
||||||
|
this.running = false;
|
||||||
|
this.activeRunId = null;
|
||||||
|
},
|
||||||
|
|
||||||
async collectTests() {
|
async collectTests() {
|
||||||
this.collecting = true;
|
this.collecting = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user