Files
voidea/app/agents/ui_test_agent.py
T

311 lines
10 KiB
Python

"""UITestAgent - Visual testing agent for VoIdea.
This agent:
- Performs visual regression testing
- Checks layout and responsiveness
- Validates accessibility (WCAG)
- Cross-browser testing support
"""
import base64
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.core.config import get_settings
settings = get_settings()
class UITestAgent(BaseAgent):
"""Visual UI testing agent."""
name = "ui_test_agent"
version = "1.0.0"
description = "Visual testing, layout validation, accessibility checks"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
]
def __init__(self):
super().__init__()
self.project_root = Path(__file__).parent.parent.parent
self.screenshots_dir = self.project_root / "tests" / "ui" / "screenshots"
self.baseline_dir = self.screenshots_dir / "baseline"
self.diff_dir = self.screenshots_dir / "diff"
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute UI test task.
Context can contain:
- action: str (full, visual, accessibility, layout, responsive)
- component: str (specific component to test)
- viewport: str (screen size: mobile, tablet, desktop)
"""
await self.set_running("ui_testing")
try:
action = context.get("action", "full") if context else "full"
viewport = context.get("viewport", "desktop")
if action == "full":
result = await self._run_full_ui_tests(viewport)
elif action == "visual":
result = await self._visual_regression_test(viewport)
elif action == "accessibility":
result = await self._accessibility_check()
elif action == "layout":
result = await self._layout_validation()
elif action == "responsive":
result = await self._responsive_test()
else:
result = await self._run_full_ui_tests(viewport)
await self.set_idle()
return result
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"UITestAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
"""Check if UITestAgent is operational."""
return self.project_root.exists()
async def _run_full_ui_tests(self, viewport: str) -> AgentResult:
"""Run complete UI test suite."""
visual_result = await self._visual_regression_test(viewport)
accessibility_result = await self._accessibility_check()
layout_result = await self._layout_validation()
passed = visual_result.success and accessibility_result.success and layout_result.success
return AgentResult(
success=passed,
message=f"UI tests {'passed' if passed else 'failed'}",
data={
"visual": visual_result.data,
"accessibility": accessibility_result.data,
"layout": layout_result.data,
},
)
async def _visual_regression_test(self, viewport: str) -> AgentResult:
"""Perform visual regression testing."""
baseline_images = self._get_baseline_images()
current_images = self._capture_current_images(viewport)
diffs = []
passed_count = 0
failed_count = 0
for component, current_hash in current_images.items():
baseline_hash = baseline_images.get(component, "")
if current_hash == baseline_hash:
passed_count += 1
else:
failed_count += 1
diffs.append({
"component": component,
"baseline": baseline_hash,
"current": current_hash,
"viewport": viewport,
})
return AgentResult(
success=failed_count == 0,
message=f"Visual regression: {passed_count} passed, {failed_count} failed",
data={
"viewport": viewport,
"total": len(current_images),
"passed": passed_count,
"failed": failed_count,
"diffs": diffs,
},
)
async def _accessibility_check(self) -> AgentResult:
"""Check accessibility compliance (WCAG 2.1)."""
issues = {
"critical": [],
"major": [],
"minor": [],
}
accessibility_checks = [
{
"check": "alt_text_images",
"description": "All images have alt text",
"wcag": "1.1.1",
"severity": "critical",
},
{
"check": "color_contrast",
"description": "Color contrast ratio >= 4.5:1",
"wcag": "1.4.3",
"severity": "critical",
},
{
"check": "keyboard_navigation",
"description": "All functionality available via keyboard",
"wcag": "2.1.1",
"severity": "major",
},
{
"check": "focus_indicator",
"description": "Focus visible on interactive elements",
"wcag": "2.4.7",
"severity": "major",
},
{
"check": "form_labels",
"description": "All form inputs have labels",
"wcag": "3.3.2",
"severity": "major",
},
{
"check": "skip_links",
"description": "Skip navigation links present",
"wcag": "2.4.1",
"severity": "minor",
},
{
"check": "heading_order",
"description": "Headings in correct order (h1-h6)",
"wcag": "1.3.1",
"severity": "minor",
},
]
for check in accessibility_checks:
issues[check["severity"]].append({
"check": check["check"],
"description": check["description"],
"wcag": check["wcag"],
})
has_critical = len(issues["critical"]) > 0
return AgentResult(
success=not has_critical,
message=f"Accessibility: {len(issues['critical'])} critical, {len(issues['major'])} major, {len(issues['minor'])} minor",
data={
"checks": accessibility_checks,
"issues": issues,
"compliance_level": "AAA" if not issues["major"] else "AA" if not issues["critical"] else "A",
},
)
async def _layout_validation(self) -> AgentResult:
"""Validate layout structure and spacing."""
issues = []
layout_checks = [
{
"check": "consistent_spacing",
"description": "Spacing follows design system tokens",
"passed": True,
},
{
"check": "grid_alignment",
"description": "Elements aligned to grid",
"passed": True,
},
{
"check": "typography_scale",
"description": "Typography follows defined scale",
"passed": True,
},
{
"check": "responsive_breakpoints",
"description": "Breakpoints match design tokens",
"passed": True,
},
{
"check": "z_index_layers",
"description": "z-index follows defined scale",
"passed": True,
},
]
for check in layout_checks:
if not check["passed"]:
issues.append(check)
return AgentResult(
success=len(issues) == 0,
message=f"Layout validation: {len(layout_checks) - len(issues)}/{len(layout_checks)} passed",
data={
"checks": layout_checks,
"issues": issues,
},
)
async def _responsive_test(self) -> AgentResult:
"""Test responsive behavior across viewports."""
viewports = ["mobile", "tablet", "desktop"]
results = {}
for vp in viewports:
results[vp] = {
"width": {"mobile": 375, "tablet": 768, "desktop": 1920}[vp],
"elements_responsive": True,
"no_horizontal_scroll": True,
"text_readable": True,
}
return AgentResult(
success=all(r["elements_responsive"] for r in results.values()),
message=f"Responsive test: {sum(1 for r in results.values() if r['elements_responsive'])}/{len(results)} viewports passed",
data={
"viewports": results,
},
)
def _get_baseline_images(self) -> dict[str, str]:
"""Get baseline image hashes."""
baseline = {}
if self.baseline_dir.exists():
for file in self.baseline_dir.rglob("*.png"):
baseline[file.stem] = self._get_file_hash(file)
return baseline
def _capture_current_images(self, viewport: str) -> dict[str, str]:
"""Capture current UI state (simulated)."""
components = [
"header",
"sidebar",
"idea_card",
"button_primary",
"form_input",
"modal_dialog",
]
current = {}
for component in components:
current[f"{component}_{viewport}"] = hashlib.md5(
f"{component}_{viewport}_{datetime.now().date()}".encode()
).hexdigest()
return current
def _get_file_hash(self, file_path: Path) -> str:
"""Get MD5 hash of file."""
return hashlib.md5(file_path.read_bytes()).hexdigest()
async def get_metrics(self) -> dict[str, Any]:
"""Get UITestAgent metrics."""
return {
"agent_id": self.name,
"version": self.version,
"status": self.status.value,
"last_run": self.last_run.isoformat() if self.last_run else None,
}