Add a new Testing Dashboard page that replaces the old Testing Hub with pytest integration: - Database models for test runs, results, and collections (TestRun, TestResult, TestCollection) - Test runner service that executes pytest with JSON reporting and stores results in the database - REST API endpoints for running tests, viewing history, and statistics - Dashboard UI showing pass rates, trends, tests by category, and top failing tests - Alembic migration for the new test_* tables The dashboard allows admins to: - Run pytest directly from the UI - View test run history with pass/fail statistics - See trend data across recent runs - Identify frequently failing tests - Collect test information without running 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
104 lines
4.9 KiB
Python
104 lines
4.9 KiB
Python
"""add_test_run_tables
|
|
|
|
Revision ID: 82ea1b4a3ccb
|
|
Revises: b4c5d6e7f8a9
|
|
Create Date: 2025-12-12 22:48:09.501172
|
|
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision: str = '82ea1b4a3ccb'
|
|
down_revision: Union[str, None] = 'b4c5d6e7f8a9'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# Create test_collections table
|
|
op.create_table('test_collections',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('total_tests', sa.Integer(), nullable=True),
|
|
sa.Column('total_files', sa.Integer(), nullable=True),
|
|
sa.Column('total_classes', sa.Integer(), nullable=True),
|
|
sa.Column('unit_tests', sa.Integer(), nullable=True),
|
|
sa.Column('integration_tests', sa.Integer(), nullable=True),
|
|
sa.Column('performance_tests', sa.Integer(), nullable=True),
|
|
sa.Column('system_tests', sa.Integer(), nullable=True),
|
|
sa.Column('test_files', sa.JSON(), nullable=True),
|
|
sa.Column('collected_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
|
sa.PrimaryKeyConstraint('id')
|
|
)
|
|
op.create_index(op.f('ix_test_collections_id'), 'test_collections', ['id'], unique=False)
|
|
|
|
# Create test_runs table
|
|
op.create_table('test_runs',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
|
sa.Column('total_tests', sa.Integer(), nullable=True),
|
|
sa.Column('passed', sa.Integer(), nullable=True),
|
|
sa.Column('failed', sa.Integer(), nullable=True),
|
|
sa.Column('errors', sa.Integer(), nullable=True),
|
|
sa.Column('skipped', sa.Integer(), nullable=True),
|
|
sa.Column('xfailed', sa.Integer(), nullable=True),
|
|
sa.Column('xpassed', sa.Integer(), nullable=True),
|
|
sa.Column('coverage_percent', sa.Float(), nullable=True),
|
|
sa.Column('duration_seconds', sa.Float(), nullable=True),
|
|
sa.Column('triggered_by', sa.String(length=100), nullable=True),
|
|
sa.Column('git_commit_hash', sa.String(length=40), nullable=True),
|
|
sa.Column('git_branch', sa.String(length=100), nullable=True),
|
|
sa.Column('test_path', sa.String(length=500), nullable=True),
|
|
sa.Column('pytest_args', sa.String(length=500), nullable=True),
|
|
sa.Column('status', sa.String(length=20), nullable=True),
|
|
sa.PrimaryKeyConstraint('id')
|
|
)
|
|
op.create_index(op.f('ix_test_runs_id'), 'test_runs', ['id'], unique=False)
|
|
op.create_index(op.f('ix_test_runs_status'), 'test_runs', ['status'], unique=False)
|
|
op.create_index(op.f('ix_test_runs_timestamp'), 'test_runs', ['timestamp'], unique=False)
|
|
|
|
# Create test_results table
|
|
op.create_table('test_results',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('run_id', sa.Integer(), nullable=False),
|
|
sa.Column('node_id', sa.String(length=500), nullable=False),
|
|
sa.Column('test_name', sa.String(length=200), nullable=False),
|
|
sa.Column('test_file', sa.String(length=300), nullable=False),
|
|
sa.Column('test_class', sa.String(length=200), nullable=True),
|
|
sa.Column('outcome', sa.String(length=20), nullable=False),
|
|
sa.Column('duration_seconds', sa.Float(), nullable=True),
|
|
sa.Column('error_message', sa.Text(), nullable=True),
|
|
sa.Column('traceback', sa.Text(), nullable=True),
|
|
sa.Column('markers', sa.JSON(), nullable=True),
|
|
sa.Column('parameters', sa.JSON(), nullable=True),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
|
sa.ForeignKeyConstraint(['run_id'], ['test_runs.id'], ),
|
|
sa.PrimaryKeyConstraint('id')
|
|
)
|
|
op.create_index(op.f('ix_test_results_id'), 'test_results', ['id'], unique=False)
|
|
op.create_index(op.f('ix_test_results_node_id'), 'test_results', ['node_id'], unique=False)
|
|
op.create_index(op.f('ix_test_results_outcome'), 'test_results', ['outcome'], unique=False)
|
|
op.create_index(op.f('ix_test_results_run_id'), 'test_results', ['run_id'], unique=False)
|
|
|
|
|
|
def downgrade() -> None:
|
|
# Drop test_results table first (has foreign key to test_runs)
|
|
op.drop_index(op.f('ix_test_results_run_id'), table_name='test_results')
|
|
op.drop_index(op.f('ix_test_results_outcome'), table_name='test_results')
|
|
op.drop_index(op.f('ix_test_results_node_id'), table_name='test_results')
|
|
op.drop_index(op.f('ix_test_results_id'), table_name='test_results')
|
|
op.drop_table('test_results')
|
|
|
|
# Drop test_runs table
|
|
op.drop_index(op.f('ix_test_runs_timestamp'), table_name='test_runs')
|
|
op.drop_index(op.f('ix_test_runs_status'), table_name='test_runs')
|
|
op.drop_index(op.f('ix_test_runs_id'), table_name='test_runs')
|
|
op.drop_table('test_runs')
|
|
|
|
# Drop test_collections table
|
|
op.drop_index(op.f('ix_test_collections_id'), table_name='test_collections')
|
|
op.drop_table('test_collections')
|