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:
2026-01-24 21:18:18 -05:00
parent a3f18dc377
commit 3193831340
24 changed files with 6437 additions and 0 deletions

View 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

View File

@@ -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;

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View 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 });
});
});