311 lines
10 KiB
Python
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,
|
|
} |