feat: Add DevLoop automation and HTML Reports
## DevLoop - Closed-Loop Development System - Orchestrator for plan → build → test → analyze cycle - Gemini planning via OpenCode CLI - Claude implementation via CLI bridge - Playwright browser testing integration - Test runner with API, filesystem, and browser tests - Persistent state in .devloop/ directory - CLI tool: tools/devloop_cli.py Usage: python tools/devloop_cli.py start 'Create new feature' python tools/devloop_cli.py plan 'Fix bug in X' python tools/devloop_cli.py test --study support_arm python tools/devloop_cli.py browser --level full ## HTML Reports (optimization_engine/reporting/) - Interactive Plotly-based reports - Convergence plot, Pareto front, parallel coordinates - Parameter importance analysis - Self-contained HTML (offline-capable) - Tailwind CSS styling ## Playwright E2E Tests - Home page tests - Test results in test-results/ ## LAC Knowledge Base Updates - Session insights (failures, workarounds, patterns) - Optimization memory for arm support study
This commit is contained in:
416
atomizer-dashboard/backend/api/routes/devloop.py
Normal file
416
atomizer-dashboard/backend/api/routes/devloop.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
DevLoop API Endpoints - Closed-loop development orchestration.
|
||||
|
||||
Provides REST API and WebSocket for:
|
||||
- Starting/stopping development cycles
|
||||
- Monitoring progress
|
||||
- Executing single phases
|
||||
- Viewing history and learnings
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, BackgroundTasks
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any, Dict, List, Optional
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Add project root to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
|
||||
router = APIRouter(prefix="/devloop", tags=["devloop"])
|
||||
|
||||
# Global orchestrator instance
|
||||
_orchestrator = None
|
||||
_active_cycle = None
|
||||
_websocket_clients: List[WebSocket] = []
|
||||
|
||||
|
||||
def get_orchestrator():
|
||||
"""Get or create the DevLoop orchestrator."""
|
||||
global _orchestrator
|
||||
if _orchestrator is None:
|
||||
from optimization_engine.devloop import DevLoopOrchestrator
|
||||
|
||||
_orchestrator = DevLoopOrchestrator(
|
||||
{
|
||||
"dashboard_url": "http://localhost:8000",
|
||||
"websocket_url": "ws://localhost:8000",
|
||||
"studies_dir": str(Path(__file__).parent.parent.parent.parent.parent / "studies"),
|
||||
"learning_enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Subscribe to state updates
|
||||
_orchestrator.subscribe(_broadcast_state_update)
|
||||
|
||||
return _orchestrator
|
||||
|
||||
|
||||
def _broadcast_state_update(state):
|
||||
"""Broadcast state updates to all WebSocket clients."""
|
||||
asyncio.create_task(
|
||||
_send_to_all_clients(
|
||||
{
|
||||
"type": "state_update",
|
||||
"state": {
|
||||
"phase": state.phase.value,
|
||||
"iteration": state.iteration,
|
||||
"current_task": state.current_task,
|
||||
"last_update": state.last_update,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _send_to_all_clients(message: Dict):
|
||||
"""Send message to all connected WebSocket clients."""
|
||||
disconnected = []
|
||||
for client in _websocket_clients:
|
||||
try:
|
||||
await client.send_json(message)
|
||||
except Exception:
|
||||
disconnected.append(client)
|
||||
|
||||
# Clean up disconnected clients
|
||||
for client in disconnected:
|
||||
if client in _websocket_clients:
|
||||
_websocket_clients.remove(client)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class StartCycleRequest(BaseModel):
|
||||
"""Request to start a development cycle."""
|
||||
|
||||
objective: str = Field(..., description="What to achieve")
|
||||
context: Optional[Dict[str, Any]] = Field(default=None, description="Additional context")
|
||||
max_iterations: Optional[int] = Field(default=10, description="Maximum iterations")
|
||||
|
||||
|
||||
class StepRequest(BaseModel):
|
||||
"""Request to execute a single step."""
|
||||
|
||||
phase: str = Field(..., description="Phase to execute: plan, implement, test, analyze")
|
||||
data: Optional[Dict[str, Any]] = Field(default=None, description="Phase-specific data")
|
||||
|
||||
|
||||
class CycleStatusResponse(BaseModel):
|
||||
"""Response with cycle status."""
|
||||
|
||||
active: bool
|
||||
phase: str
|
||||
iteration: int
|
||||
current_task: Optional[str]
|
||||
last_update: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REST Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status() -> CycleStatusResponse:
|
||||
"""Get current DevLoop status."""
|
||||
orchestrator = get_orchestrator()
|
||||
state = orchestrator.get_state()
|
||||
|
||||
return CycleStatusResponse(
|
||||
active=state["phase"] != "idle",
|
||||
phase=state["phase"],
|
||||
iteration=state["iteration"],
|
||||
current_task=state.get("current_task"),
|
||||
last_update=state["last_update"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def start_cycle(request: StartCycleRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Start a new development cycle.
|
||||
|
||||
The cycle runs in the background and broadcasts progress via WebSocket.
|
||||
"""
|
||||
global _active_cycle
|
||||
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
# Check if already running
|
||||
if orchestrator.state.phase.value != "idle":
|
||||
raise HTTPException(status_code=409, detail="A development cycle is already running")
|
||||
|
||||
# Start cycle in background
|
||||
async def run_cycle():
|
||||
global _active_cycle
|
||||
try:
|
||||
result = await orchestrator.run_development_cycle(
|
||||
objective=request.objective,
|
||||
context=request.context,
|
||||
max_iterations=request.max_iterations,
|
||||
)
|
||||
_active_cycle = result
|
||||
|
||||
# Broadcast completion
|
||||
await _send_to_all_clients(
|
||||
{
|
||||
"type": "cycle_complete",
|
||||
"result": {
|
||||
"objective": result.objective,
|
||||
"status": result.status,
|
||||
"iterations": len(result.iterations),
|
||||
"duration_seconds": result.total_duration_seconds,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
await _send_to_all_clients({"type": "cycle_error", "error": str(e)})
|
||||
|
||||
background_tasks.add_task(run_cycle)
|
||||
|
||||
return {
|
||||
"message": "Development cycle started",
|
||||
"objective": request.objective,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_cycle():
|
||||
"""Stop the current development cycle."""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
if orchestrator.state.phase.value == "idle":
|
||||
raise HTTPException(status_code=400, detail="No active cycle to stop")
|
||||
|
||||
# Set state to idle (will stop at next phase boundary)
|
||||
orchestrator._update_state(phase=orchestrator.state.phase.__class__.IDLE, task="Stopping...")
|
||||
|
||||
return {"message": "Cycle stop requested"}
|
||||
|
||||
|
||||
@router.post("/step")
|
||||
async def execute_step(request: StepRequest):
|
||||
"""
|
||||
Execute a single phase step.
|
||||
|
||||
Useful for manual control or debugging.
|
||||
"""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
if request.phase == "plan":
|
||||
objective = request.data.get("objective", "") if request.data else ""
|
||||
context = request.data.get("context") if request.data else None
|
||||
result = await orchestrator.step_plan(objective, context)
|
||||
|
||||
elif request.phase == "implement":
|
||||
plan = request.data if request.data else {}
|
||||
result = await orchestrator.step_implement(plan)
|
||||
|
||||
elif request.phase == "test":
|
||||
scenarios = request.data.get("scenarios", []) if request.data else []
|
||||
result = await orchestrator.step_test(scenarios)
|
||||
|
||||
elif request.phase == "analyze":
|
||||
test_results = request.data if request.data else {}
|
||||
result = await orchestrator.step_analyze(test_results)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown phase: {request.phase}. Valid: plan, implement, test, analyze",
|
||||
)
|
||||
|
||||
return {"phase": request.phase, "result": result}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_history():
|
||||
"""Get history of past development cycles."""
|
||||
orchestrator = get_orchestrator()
|
||||
return orchestrator.export_history()
|
||||
|
||||
|
||||
@router.get("/last-cycle")
|
||||
async def get_last_cycle():
|
||||
"""Get details of the most recent cycle."""
|
||||
global _active_cycle
|
||||
|
||||
if _active_cycle is None:
|
||||
raise HTTPException(status_code=404, detail="No cycle has been run yet")
|
||||
|
||||
return {
|
||||
"objective": _active_cycle.objective,
|
||||
"status": _active_cycle.status,
|
||||
"start_time": _active_cycle.start_time,
|
||||
"end_time": _active_cycle.end_time,
|
||||
"iterations": [
|
||||
{
|
||||
"iteration": it.iteration,
|
||||
"success": it.success,
|
||||
"duration_seconds": it.duration_seconds,
|
||||
"has_plan": it.plan is not None,
|
||||
"has_tests": it.test_results is not None,
|
||||
"has_fixes": it.fixes is not None,
|
||||
}
|
||||
for it in _active_cycle.iterations
|
||||
],
|
||||
"total_duration_seconds": _active_cycle.total_duration_seconds,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""Check DevLoop system health."""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
# Check dashboard connection
|
||||
from optimization_engine.devloop import DashboardTestRunner
|
||||
|
||||
runner = DashboardTestRunner()
|
||||
dashboard_health = await runner.run_health_check()
|
||||
|
||||
return {
|
||||
"devloop": "healthy",
|
||||
"orchestrator_state": orchestrator.get_state()["phase"],
|
||||
"dashboard": dashboard_health,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WebSocket Endpoint
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""
|
||||
WebSocket endpoint for real-time DevLoop updates.
|
||||
|
||||
Messages sent:
|
||||
- state_update: Phase/iteration changes
|
||||
- cycle_complete: Cycle finished
|
||||
- cycle_error: Cycle failed
|
||||
- test_progress: Individual test results
|
||||
"""
|
||||
await websocket.accept()
|
||||
_websocket_clients.append(websocket)
|
||||
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
try:
|
||||
# Send initial state
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "connection_ack",
|
||||
"state": orchestrator.get_state(),
|
||||
}
|
||||
)
|
||||
|
||||
# Handle incoming messages
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.wait_for(websocket.receive_json(), timeout=30.0)
|
||||
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
elif msg_type == "get_state":
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "state",
|
||||
"state": orchestrator.get_state(),
|
||||
}
|
||||
)
|
||||
|
||||
elif msg_type == "start_cycle":
|
||||
# Allow starting cycle via WebSocket
|
||||
objective = data.get("objective", "")
|
||||
context = data.get("context")
|
||||
|
||||
asyncio.create_task(orchestrator.run_development_cycle(objective, context))
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "cycle_started",
|
||||
"objective": objective,
|
||||
}
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Send heartbeat
|
||||
await websocket.send_json({"type": "heartbeat"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
if websocket in _websocket_clients:
|
||||
_websocket_clients.remove(websocket)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Convenience Endpoints for Common Tasks
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("/create-study")
|
||||
async def create_study_cycle(
|
||||
study_name: str,
|
||||
problem_statement: Optional[str] = None,
|
||||
background_tasks: BackgroundTasks = None,
|
||||
):
|
||||
"""
|
||||
Convenience endpoint to start a study creation cycle.
|
||||
|
||||
This is a common workflow that combines planning, implementation, and testing.
|
||||
"""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
context = {
|
||||
"study_name": study_name,
|
||||
"task_type": "create_study",
|
||||
}
|
||||
|
||||
if problem_statement:
|
||||
context["problem_statement"] = problem_statement
|
||||
|
||||
# Start the cycle
|
||||
async def run_cycle():
|
||||
result = await orchestrator.run_development_cycle(
|
||||
objective=f"Create optimization study: {study_name}",
|
||||
context=context,
|
||||
)
|
||||
return result
|
||||
|
||||
if background_tasks:
|
||||
background_tasks.add_task(run_cycle)
|
||||
return {"message": f"Study creation cycle started for '{study_name}'"}
|
||||
else:
|
||||
result = await run_cycle()
|
||||
return {
|
||||
"message": f"Study '{study_name}' creation completed",
|
||||
"status": result.status,
|
||||
"iterations": len(result.iterations),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/run-tests")
|
||||
async def run_tests(scenarios: List[Dict[str, Any]]):
|
||||
"""
|
||||
Run a set of test scenarios directly.
|
||||
|
||||
Useful for testing specific features without a full cycle.
|
||||
"""
|
||||
from optimization_engine.devloop import DashboardTestRunner
|
||||
|
||||
runner = DashboardTestRunner()
|
||||
results = await runner.run_test_suite(scenarios)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* DevLoopPanel - Control panel for closed-loop development
|
||||
*
|
||||
* Features:
|
||||
* - Start/stop development cycles
|
||||
* - Real-time phase monitoring
|
||||
* - Iteration history view
|
||||
* - Test result visualization
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
PlayCircle,
|
||||
StopCircle,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ListChecks,
|
||||
Zap,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
|
||||
interface LoopState {
|
||||
phase: string;
|
||||
iteration: number;
|
||||
current_task: string | null;
|
||||
last_update: string;
|
||||
}
|
||||
|
||||
interface CycleResult {
|
||||
objective: string;
|
||||
status: string;
|
||||
iterations: number;
|
||||
duration_seconds: number;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
scenario_id: string;
|
||||
scenario_name: string;
|
||||
passed: boolean;
|
||||
duration_ms: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const PHASE_COLORS: Record<string, string> = {
|
||||
idle: 'bg-gray-500',
|
||||
planning: 'bg-blue-500',
|
||||
implementing: 'bg-purple-500',
|
||||
testing: 'bg-yellow-500',
|
||||
analyzing: 'bg-orange-500',
|
||||
fixing: 'bg-red-500',
|
||||
verifying: 'bg-green-500',
|
||||
};
|
||||
|
||||
const PHASE_ICONS: Record<string, React.ReactNode> = {
|
||||
idle: <Clock className="w-4 h-4" />,
|
||||
planning: <ListChecks className="w-4 h-4" />,
|
||||
implementing: <Zap className="w-4 h-4" />,
|
||||
testing: <RefreshCw className="w-4 h-4 animate-spin" />,
|
||||
analyzing: <AlertCircle className="w-4 h-4" />,
|
||||
fixing: <Zap className="w-4 h-4" />,
|
||||
verifying: <CheckCircle className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
export function DevLoopPanel() {
|
||||
const [state, setState] = useState<LoopState>({
|
||||
phase: 'idle',
|
||||
iteration: 0,
|
||||
current_task: null,
|
||||
last_update: new Date().toISOString(),
|
||||
});
|
||||
const [objective, setObjective] = useState('');
|
||||
const [history, setHistory] = useState<CycleResult[]>([]);
|
||||
const [testResults, setTestResults] = useState<TestResult[]>([]);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
// WebSocket connection for real-time updates
|
||||
const { lastJsonMessage, readyState } = useWebSocket(
|
||||
'ws://localhost:8000/api/devloop/ws',
|
||||
{
|
||||
shouldReconnect: () => true,
|
||||
reconnectInterval: 3000,
|
||||
}
|
||||
);
|
||||
|
||||
// Handle WebSocket messages
|
||||
useEffect(() => {
|
||||
if (!lastJsonMessage) return;
|
||||
|
||||
const msg = lastJsonMessage as any;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'connection_ack':
|
||||
case 'state_update':
|
||||
case 'state':
|
||||
if (msg.state) {
|
||||
setState(msg.state);
|
||||
}
|
||||
break;
|
||||
case 'cycle_complete':
|
||||
setHistory(prev => [msg.result, ...prev].slice(0, 10));
|
||||
setIsStarting(false);
|
||||
break;
|
||||
case 'cycle_error':
|
||||
console.error('DevLoop error:', msg.error);
|
||||
setIsStarting(false);
|
||||
break;
|
||||
case 'test_progress':
|
||||
if (msg.result) {
|
||||
setTestResults(prev => [...prev, msg.result]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [lastJsonMessage]);
|
||||
|
||||
// Start a development cycle
|
||||
const startCycle = useCallback(async () => {
|
||||
if (!objective.trim()) return;
|
||||
|
||||
setIsStarting(true);
|
||||
setTestResults([]);
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/devloop/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
objective: objective.trim(),
|
||||
max_iterations: 10,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('Failed to start cycle:', error);
|
||||
setIsStarting(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start cycle:', error);
|
||||
setIsStarting(false);
|
||||
}
|
||||
}, [objective]);
|
||||
|
||||
// Stop the current cycle
|
||||
const stopCycle = useCallback(async () => {
|
||||
try {
|
||||
await fetch('http://localhost:8000/api/devloop/stop', {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to stop cycle:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Quick start: Create support_arm study
|
||||
const quickStartSupportArm = useCallback(() => {
|
||||
setObjective('Create support_arm optimization study with 5 design variables (center_space, arm_thk, arm_angle, end_thk, base_thk), objectives (minimize displacement, minimize mass), and stress constraint (< 30% yield)');
|
||||
// Auto-start after a brief delay
|
||||
setTimeout(() => {
|
||||
startCycle();
|
||||
}, 500);
|
||||
}, [startCycle]);
|
||||
|
||||
const isActive = state.phase !== 'idle';
|
||||
const wsConnected = readyState === WebSocket.OPEN;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 bg-gray-800 cursor-pointer"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<RefreshCw className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-white">DevLoop Control</h3>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
wsConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className={`px-2 py-1 text-xs rounded ${PHASE_COLORS[state.phase]} text-white`}>
|
||||
{state.phase.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Objective Input */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Development Objective
|
||||
</label>
|
||||
<textarea
|
||||
value={objective}
|
||||
onChange={(e) => setObjective(e.target.value)}
|
||||
placeholder="e.g., Create support_arm optimization study..."
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white text-sm resize-none h-20"
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={quickStartSupportArm}
|
||||
disabled={isActive}
|
||||
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white text-sm rounded flex items-center gap-1"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Quick: support_arm
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex gap-2">
|
||||
{!isActive ? (
|
||||
<button
|
||||
onClick={startCycle}
|
||||
disabled={!objective.trim() || isStarting}
|
||||
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded flex items-center justify-center gap-2"
|
||||
>
|
||||
<PlayCircle className="w-5 h-5" />
|
||||
{isStarting ? 'Starting...' : 'Start Cycle'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={stopCycle}
|
||||
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded flex items-center justify-center gap-2"
|
||||
>
|
||||
<StopCircle className="w-5 h-5" />
|
||||
Stop Cycle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Phase Progress */}
|
||||
{isActive && (
|
||||
<div className="bg-gray-800 rounded p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{PHASE_ICONS[state.phase]}
|
||||
<span className="text-sm text-white font-medium">
|
||||
{state.phase.charAt(0).toUpperCase() + state.phase.slice(1)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
Iteration {state.iteration + 1}
|
||||
</span>
|
||||
</div>
|
||||
{state.current_task && (
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{state.current_task}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Results */}
|
||||
{testResults.length > 0 && (
|
||||
<div className="bg-gray-800 rounded p-3">
|
||||
<h4 className="text-sm font-medium text-white mb-2">Test Results</h4>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{testResults.map((test, i) => (
|
||||
<div
|
||||
key={`${test.scenario_id}-${i}`}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
{test.passed ? (
|
||||
<CheckCircle className="w-3 h-3 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-3 h-3 text-red-500" />
|
||||
)}
|
||||
<span className="text-gray-300 truncate flex-1">
|
||||
{test.scenario_name}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{test.duration_ms.toFixed(0)}ms
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{history.length > 0 && (
|
||||
<div className="bg-gray-800 rounded p-3">
|
||||
<h4 className="text-sm font-medium text-white mb-2">Recent Cycles</h4>
|
||||
<div className="space-y-2">
|
||||
{history.slice(0, 3).map((cycle, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="text-gray-300 truncate flex-1">
|
||||
{cycle.objective.substring(0, 40)}...
|
||||
</span>
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded ${
|
||||
cycle.status === 'completed'
|
||||
? 'bg-green-900 text-green-300'
|
||||
: 'bg-yellow-900 text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
{cycle.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase Legend */}
|
||||
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||
{Object.entries(PHASE_COLORS).map(([phase, color]) => (
|
||||
<div key={phase} className="flex items-center gap-1">
|
||||
<div className={`w-2 h-2 rounded ${color}`} />
|
||||
<span className="text-gray-400 capitalize">{phase}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DevLoopPanel;
|
||||
4
atomizer-dashboard/frontend/test-results/.last-run.json
Normal file
4
atomizer-dashboard/frontend/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
171
atomizer-dashboard/frontend/tests/e2e/home.spec.ts
Normal file
171
atomizer-dashboard/frontend/tests/e2e/home.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Home Page E2E Tests
|
||||
*
|
||||
* Tests the study list page at /
|
||||
* Covers: study loading, topic expansion, navigation
|
||||
*/
|
||||
|
||||
test.describe('Home Page - Study List', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to home page
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('displays page header', async ({ page }) => {
|
||||
// Check header is visible
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
|
||||
// Check for key header elements - Studies heading (exact match to avoid Inbox Studies)
|
||||
await expect(page.getByRole('heading', { name: 'Studies', exact: true })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('shows aggregate statistics cards', async ({ page }) => {
|
||||
// Wait for stats to load
|
||||
await expect(page.getByText('Total Studies')).toBeVisible();
|
||||
await expect(page.getByText('Running')).toBeVisible();
|
||||
await expect(page.getByText('Total Trials')).toBeVisible();
|
||||
await expect(page.getByText('Best Overall')).toBeVisible();
|
||||
});
|
||||
|
||||
test('loads studies table with topic folders', async ({ page }) => {
|
||||
// Wait for studies section (exact match to avoid Inbox Studies)
|
||||
await expect(page.getByRole('heading', { name: 'Studies', exact: true })).toBeVisible();
|
||||
|
||||
// Wait for loading to complete - either see folders or empty state
|
||||
// Folders have "trials" text in them
|
||||
const folderLocator = page.locator('button:has-text("trials")');
|
||||
const emptyStateLocator = page.getByText('No studies found');
|
||||
|
||||
// Wait for either studies loaded or empty state (10s timeout)
|
||||
await expect(folderLocator.first().or(emptyStateLocator)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('expands topic folder to show studies', async ({ page }) => {
|
||||
// Wait for folders to load
|
||||
const folderButton = page.locator('button:has-text("trials")').first();
|
||||
|
||||
// Wait for folder to be visible (studies loaded)
|
||||
await expect(folderButton).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click to expand
|
||||
await folderButton.click();
|
||||
|
||||
// After expansion, study rows should be visible (they have status badges)
|
||||
// Status badges contain: running, completed, idle, paused, not_started
|
||||
const statusBadges = page.locator('span:has-text("running"), span:has-text("completed"), span:has-text("idle"), span:has-text("paused"), span:has-text("not_started")');
|
||||
await expect(statusBadges.first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('clicking study shows preview panel', async ({ page }) => {
|
||||
// Wait for and expand first folder
|
||||
const folderButton = page.locator('button:has-text("trials")').first();
|
||||
await expect(folderButton).toBeVisible({ timeout: 10000 });
|
||||
await folderButton.click();
|
||||
|
||||
// Wait for expanded content and click first study row
|
||||
const studyRow = page.locator('.bg-dark-850\\/50 > div').first();
|
||||
await expect(studyRow).toBeVisible({ timeout: 5000 });
|
||||
await studyRow.click();
|
||||
|
||||
// Preview panel should show with buttons - use exact match to avoid header nav button
|
||||
await expect(page.getByRole('button', { name: 'Canvas', exact: true })).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole('button', { name: 'Open' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Open button navigates to dashboard', async ({ page }) => {
|
||||
// Wait for and expand first folder
|
||||
const folderButton = page.locator('button:has-text("trials")').first();
|
||||
await expect(folderButton).toBeVisible({ timeout: 10000 });
|
||||
await folderButton.click();
|
||||
|
||||
// Wait for and click study row
|
||||
const studyRow = page.locator('.bg-dark-850\\/50 > div').first();
|
||||
await expect(studyRow).toBeVisible({ timeout: 5000 });
|
||||
await studyRow.click();
|
||||
|
||||
// Wait for and click Open button
|
||||
const openButton = page.getByRole('button', { name: 'Open' });
|
||||
await expect(openButton).toBeVisible({ timeout: 5000 });
|
||||
await openButton.click();
|
||||
|
||||
// Should navigate to dashboard
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
});
|
||||
|
||||
test('Canvas button navigates to canvas view', async ({ page }) => {
|
||||
// Wait for and expand first folder
|
||||
const folderButton = page.locator('button:has-text("trials")').first();
|
||||
await expect(folderButton).toBeVisible({ timeout: 10000 });
|
||||
await folderButton.click();
|
||||
|
||||
// Wait for and click study row
|
||||
const studyRow = page.locator('.bg-dark-850\\/50 > div').first();
|
||||
await expect(studyRow).toBeVisible({ timeout: 5000 });
|
||||
await studyRow.click();
|
||||
|
||||
// Wait for and click Canvas button (exact match to avoid header nav)
|
||||
const canvasButton = page.getByRole('button', { name: 'Canvas', exact: true });
|
||||
await expect(canvasButton).toBeVisible({ timeout: 5000 });
|
||||
await canvasButton.click();
|
||||
|
||||
// Should navigate to canvas
|
||||
await expect(page).toHaveURL(/\/canvas\//);
|
||||
});
|
||||
|
||||
test('refresh button reloads studies', async ({ page }) => {
|
||||
// Find the main studies section refresh button (the one with visible text "Refresh")
|
||||
const refreshButton = page.getByText('Refresh');
|
||||
await expect(refreshButton).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click refresh
|
||||
await refreshButton.click();
|
||||
|
||||
// Should show loading state or complete quickly
|
||||
// Just verify no errors occurred (exact match to avoid Inbox Studies)
|
||||
await expect(page.getByRole('heading', { name: 'Studies', exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Inbox Section Tests
|
||||
*
|
||||
* Tests the new study intake workflow
|
||||
*/
|
||||
test.describe('Home Page - Inbox Section', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('displays inbox section with header', async ({ page }) => {
|
||||
// Check for Study Inbox heading (section is expanded by default)
|
||||
const inboxHeading = page.getByRole('heading', { name: 'Study Inbox' });
|
||||
await expect(inboxHeading).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('inbox section shows pending count', async ({ page }) => {
|
||||
// Section should show pending studies count
|
||||
const pendingText = page.getByText(/\d+ pending studies/);
|
||||
await expect(pendingText).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('inbox has new study button', async ({ page }) => {
|
||||
// Section is expanded by default, look for the New Study button
|
||||
const newStudyButton = page.getByRole('button', { name: /New Study/ });
|
||||
await expect(newStudyButton).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('clicking new study shows create form', async ({ page }) => {
|
||||
// Click the New Study button
|
||||
const newStudyButton = page.getByRole('button', { name: /New Study/ });
|
||||
await expect(newStudyButton).toBeVisible({ timeout: 10000 });
|
||||
await newStudyButton.click();
|
||||
|
||||
// Form should expand with input fields
|
||||
const studyNameInput = page.getByPlaceholder(/my_study/i).or(page.locator('input[type="text"]').first());
|
||||
await expect(studyNameInput).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user