feat: single-command Docker startup, fix UI bugs, add Selenium tests
- Add `make up` / `make up DEV=1` for one-command Docker startup with optional hot-reload via docker-compose.dev.yml overlay - Add `timmy up --dev` / `timmy down` CLI commands - Fix cross-platform font resolution in creative assembler (7 test failures) - Fix Ollama host URL not passed to Agno model (container connectivity) - Fix task panel route shadowing by reordering literal routes before parameterized routes in swarm.py - Fix chat input not clearing after send (hx-on::after-request) - Fix chat scroll overflow (CSS min-height: 0 on flex children) - Add Selenium UI smoke tests (17 tests, gated behind SELENIUM_UI=1) - Install fonts-dejavu-core in Dockerfile for container font support - Remove obsolete docker-compose version key - Bump CSS cache-bust to v4 833 unit tests pass, 15 Selenium tests pass (2 skipped). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ FROM python:3.12-slim AS base
|
||||
|
||||
# ── System deps ──────────────────────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc curl \
|
||||
gcc curl fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
39
Makefile
39
Makefile
@@ -1,4 +1,5 @@
|
||||
.PHONY: install install-bigbrain dev test test-cov test-cov-html watch lint clean help \
|
||||
up down logs \
|
||||
docker-build docker-up docker-down docker-agent docker-logs docker-shell \
|
||||
cloud-deploy cloud-up cloud-down cloud-logs cloud-status cloud-update
|
||||
|
||||
@@ -77,6 +78,33 @@ lint:
|
||||
|
||||
# ── Housekeeping ──────────────────────────────────────────────────────────────
|
||||
|
||||
# ── One-command startup ──────────────────────────────────────────────────────
|
||||
# make up build + start everything in Docker
|
||||
# make up DEV=1 same, with hot-reload on Python/template/CSS changes
|
||||
|
||||
up:
|
||||
mkdir -p data
|
||||
ifdef DEV
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
@echo ""
|
||||
@echo " ✓ Timmy Time running in DEV mode at http://localhost:8000"
|
||||
@echo " Hot-reload active — Python, template, and CSS changes auto-apply"
|
||||
@echo " Logs: make logs"
|
||||
@echo ""
|
||||
else
|
||||
docker compose up -d --build
|
||||
@echo ""
|
||||
@echo " ✓ Timmy Time running at http://localhost:8000"
|
||||
@echo " Logs: make logs"
|
||||
@echo ""
|
||||
endif
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
# ── Docker ────────────────────────────────────────────────────────────────────
|
||||
|
||||
docker-build:
|
||||
@@ -150,12 +178,19 @@ clean:
|
||||
rm -rf .pytest_cache htmlcov .coverage coverage.xml
|
||||
|
||||
help:
|
||||
@echo ""
|
||||
@echo " Quick Start"
|
||||
@echo " ─────────────────────────────────────────────────"
|
||||
@echo " make up build + start everything in Docker"
|
||||
@echo " make up DEV=1 same, with hot-reload on file changes"
|
||||
@echo " make down stop all containers"
|
||||
@echo " make logs tail container logs"
|
||||
@echo ""
|
||||
@echo " Local Development"
|
||||
@echo " ─────────────────────────────────────────────────"
|
||||
@echo " make install create venv + install dev deps"
|
||||
@echo " make install-bigbrain install with AirLLM (big-model backend)"
|
||||
@echo " make dev start dashboard at http://localhost:8000"
|
||||
@echo " make dev start dashboard locally (no Docker)"
|
||||
@echo " make ip print local IP addresses for phone testing"
|
||||
@echo " make test run all tests"
|
||||
@echo " make test-cov tests + coverage report (terminal + XML)"
|
||||
@@ -164,7 +199,7 @@ help:
|
||||
@echo " make lint run ruff or flake8"
|
||||
@echo " make clean remove build artefacts and caches"
|
||||
@echo ""
|
||||
@echo " Docker (Dev)"
|
||||
@echo " Docker (Advanced)"
|
||||
@echo " ─────────────────────────────────────────────────"
|
||||
@echo " make docker-build build the timmy-time:latest image"
|
||||
@echo " make docker-up start dashboard container"
|
||||
|
||||
23
docker-compose.dev.yml
Normal file
23
docker-compose.dev.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
# ── Timmy Time — Dev-mode overlay ────────────────────────────────────────────
|
||||
#
|
||||
# Enables hot-reload: Python, template, and CSS changes auto-apply.
|
||||
#
|
||||
# Usage:
|
||||
# make up DEV=1
|
||||
# # or directly:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
|
||||
services:
|
||||
dashboard:
|
||||
command:
|
||||
- uvicorn
|
||||
- dashboard.app:app
|
||||
- --host=0.0.0.0
|
||||
- --port=8000
|
||||
- --reload
|
||||
- --reload-dir=/app/src
|
||||
- --reload-include=*.html
|
||||
- --reload-include=*.css
|
||||
- --reload-include=*.js
|
||||
environment:
|
||||
DEBUG: "true"
|
||||
@@ -15,8 +15,6 @@
|
||||
# make docker-down stop everything
|
||||
# make docker-logs tail logs
|
||||
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
|
||||
# ── Dashboard (coordinator + FastAPI) ──────────────────────────────────────
|
||||
|
||||
@@ -32,6 +32,7 @@ dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=5.0.0",
|
||||
"selenium>=4.20.0",
|
||||
]
|
||||
# Big-brain: run 8B / 70B / 405B models locally via layer-by-layer loading.
|
||||
# pip install ".[bigbrain]"
|
||||
|
||||
@@ -28,8 +28,26 @@ try:
|
||||
except ImportError:
|
||||
_MOVIEPY_AVAILABLE = False
|
||||
|
||||
# Resolve a font that actually exists on this system.
|
||||
_DEFAULT_FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
def _resolve_font() -> str:
|
||||
"""Find a usable TrueType font on the current platform."""
|
||||
candidates = [
|
||||
# Linux (Debian/Ubuntu)
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans.ttf", # Arch
|
||||
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", # Fedora
|
||||
# macOS
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/System/Library/Fonts/Helvetica.ttc",
|
||||
"/Library/Fonts/Arial.ttf",
|
||||
]
|
||||
for path in candidates:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
logger.warning("No system TrueType font found; using Pillow default")
|
||||
return "Helvetica"
|
||||
|
||||
|
||||
_DEFAULT_FONT = _resolve_font()
|
||||
|
||||
|
||||
def _require_moviepy() -> None:
|
||||
|
||||
@@ -114,6 +114,52 @@ async def post_task_and_auction(description: str = Form(...)):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tasks/panel", response_class=HTMLResponse)
|
||||
async def task_create_panel(request: Request, agent_id: Optional[str] = None):
|
||||
"""Task creation panel, optionally pre-selecting an agent."""
|
||||
agents = coordinator.list_swarm_agents()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/task_assign_panel.html",
|
||||
{"agents": agents, "preselected_agent_id": agent_id},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/direct", response_class=HTMLResponse)
|
||||
async def direct_assign_task(
|
||||
request: Request,
|
||||
description: str = Form(...),
|
||||
agent_id: Optional[str] = Form(None),
|
||||
):
|
||||
"""Create a task: assign directly if agent_id given, else open auction."""
|
||||
timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
|
||||
if agent_id:
|
||||
agent = registry.get_agent(agent_id)
|
||||
task = coordinator.post_task(description)
|
||||
coordinator.auctions.open_auction(task.id)
|
||||
coordinator.auctions.submit_bid(task.id, agent_id, 1)
|
||||
coordinator.auctions.close_auction(task.id)
|
||||
update_task(task.id, status=TaskStatus.ASSIGNED, assigned_agent=agent_id)
|
||||
registry.update_status(agent_id, "busy")
|
||||
agent_name = agent.name if agent else agent_id
|
||||
else:
|
||||
task = coordinator.post_task(description)
|
||||
winner = await coordinator.run_auction_and_assign(task.id)
|
||||
task = coordinator.get_task(task.id)
|
||||
agent_name = winner.agent_id if winner else "unassigned"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/task_result.html",
|
||||
{
|
||||
"task": task,
|
||||
"agent_name": agent_name,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task(task_id: str):
|
||||
"""Get details for a specific task."""
|
||||
@@ -268,47 +314,3 @@ async def message_agent(agent_id: str, request: Request, message: str = Form(...
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/panel", response_class=HTMLResponse)
|
||||
async def task_create_panel(request: Request, agent_id: Optional[str] = None):
|
||||
"""Task creation panel, optionally pre-selecting an agent."""
|
||||
agents = coordinator.list_swarm_agents()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/task_assign_panel.html",
|
||||
{"agents": agents, "preselected_agent_id": agent_id},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/direct", response_class=HTMLResponse)
|
||||
async def direct_assign_task(
|
||||
request: Request,
|
||||
description: str = Form(...),
|
||||
agent_id: Optional[str] = Form(None),
|
||||
):
|
||||
"""Create a task: assign directly if agent_id given, else open auction."""
|
||||
timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
|
||||
if agent_id:
|
||||
agent = registry.get_agent(agent_id)
|
||||
task = coordinator.post_task(description)
|
||||
coordinator.auctions.open_auction(task.id)
|
||||
coordinator.auctions.submit_bid(task.id, agent_id, 1)
|
||||
coordinator.auctions.close_auction(task.id)
|
||||
update_task(task.id, status=TaskStatus.ASSIGNED, assigned_agent=agent_id)
|
||||
registry.update_status(agent_id, "busy")
|
||||
agent_name = agent.name if agent else agent_id
|
||||
else:
|
||||
task = coordinator.post_task(description)
|
||||
winner = await coordinator.run_auction_and_assign(task.id)
|
||||
task = coordinator.get_task(task.id)
|
||||
agent_name = winner.agent_id if winner else "unassigned"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/task_result.html",
|
||||
{
|
||||
"task": task,
|
||||
"agent_name": agent_name,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=3" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=4" />
|
||||
{% block extra_styles %}{% endblock %}
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
hx-swap="beforeend"
|
||||
hx-indicator="#agent-send-indicator"
|
||||
hx-disabled-elt="find button"
|
||||
hx-on::after-settle="this.reset(); scrollAgentLog('{{ agent.id }}')"
|
||||
hx-on::after-request="if(event.detail.successful){this.querySelector('[name=message]').value=''; scrollAgentLog('{{ agent.id }}')}"
|
||||
class="d-flex gap-2">
|
||||
<input type="text"
|
||||
name="message"
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<div class="chat-log flex-grow-1 overflow-auto p-3" id="chat-log"
|
||||
hx-get="/agents/timmy/history"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"></div>
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-settle="scrollChat()"></div>
|
||||
|
||||
<div class="card-footer mc-chat-footer">
|
||||
<form hx-post="/agents/timmy/chat"
|
||||
@@ -27,7 +28,7 @@
|
||||
hx-indicator="#send-indicator"
|
||||
hx-sync="this:drop"
|
||||
hx-disabled-elt="find button"
|
||||
hx-on::after-settle="this.reset(); scrollChat()"
|
||||
hx-on::after-request="if(event.detail.successful){this.querySelector('[name=message]').value=''; scrollChat()}"
|
||||
class="d-flex gap-2">
|
||||
<input type="text"
|
||||
name="message"
|
||||
|
||||
@@ -68,7 +68,7 @@ def create_timmy(
|
||||
|
||||
return Agent(
|
||||
name="Timmy",
|
||||
model=Ollama(id=settings.ollama_model),
|
||||
model=Ollama(id=settings.ollama_model, host=settings.ollama_url),
|
||||
db=SqliteDb(db_file=db_file),
|
||||
description=TIMMY_SYSTEM_PROMPT,
|
||||
add_history_to_context=True,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
@@ -54,5 +55,34 @@ def status(
|
||||
timmy.print_response(TIMMY_STATUS_PROMPT, stream=False)
|
||||
|
||||
|
||||
@app.command()
|
||||
def up(
|
||||
dev: bool = typer.Option(False, "--dev", help="Enable hot-reload for development"),
|
||||
build: bool = typer.Option(True, "--build/--no-build", help="Rebuild images before starting"),
|
||||
):
|
||||
"""Start Timmy Time in Docker (dashboard + agents)."""
|
||||
cmd = ["docker", "compose"]
|
||||
if dev:
|
||||
cmd += ["-f", "docker-compose.yml", "-f", "docker-compose.dev.yml"]
|
||||
cmd += ["up", "-d"]
|
||||
if build:
|
||||
cmd.append("--build")
|
||||
|
||||
mode = "dev mode (hot-reload active)" if dev else "production mode"
|
||||
typer.echo(f"Starting Timmy Time in {mode}...")
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode == 0:
|
||||
typer.echo(f"\n Timmy Time running at http://localhost:8000 ({mode})\n")
|
||||
else:
|
||||
typer.echo("Failed to start. Is Docker running?", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def down():
|
||||
"""Stop all Timmy Time Docker containers."""
|
||||
subprocess.run(["docker", "compose", "down"], check=True)
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
@@ -272,7 +272,9 @@ a:hover { color: var(--orange); }
|
||||
}
|
||||
.mc-chat-panel > .card {
|
||||
height: 100%;
|
||||
overflow: clip;
|
||||
min-height: 0; /* flex child: allow shrink below content */
|
||||
margin-bottom: 0; /* override Bootstrap .card margin that breaks flex sizing */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Panel / Card overrides ──────────────────────── */
|
||||
@@ -396,6 +398,7 @@ a:hover { color: var(--orange); }
|
||||
/* ── Chat ────────────────────────────────────────── */
|
||||
.chat-log {
|
||||
flex: 1;
|
||||
min-height: 0; /* allow flex child to shrink below content height */
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
319
tests/functional/test_ui_selenium.py
Normal file
319
tests/functional/test_ui_selenium.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Selenium UI tests — green-path smoke tests for the dashboard.
|
||||
|
||||
Requires:
|
||||
- Dashboard running at http://localhost:8000 (make up DEV=1)
|
||||
- Chrome installed (headless mode, no display needed)
|
||||
- selenium pip package
|
||||
|
||||
Run:
|
||||
SELENIUM_UI=1 pytest tests/functional/test_ui_selenium.py -v --override-ini='pythonpath='
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
# Skip entire module unless SELENIUM_UI=1 is set
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("SELENIUM_UI") != "1",
|
||||
reason="Set SELENIUM_UI=1 to run Selenium UI tests",
|
||||
)
|
||||
|
||||
DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
# ── Prevent src/websocket from shadowing the websocket-client package ────────
|
||||
# Selenium depends on websocket-client which provides `from websocket import
|
||||
# WebSocketApp`. The project's src/websocket/ module would shadow that import.
|
||||
# Remove "src" from sys.path for this module since we don't import project code.
|
||||
_src_paths = [p for p in sys.path if p.endswith("/src") or p.endswith("\\src")]
|
||||
for _p in _src_paths:
|
||||
sys.path.remove(_p)
|
||||
|
||||
from selenium import webdriver # noqa: E402
|
||||
from selenium.webdriver.chrome.options import Options # noqa: E402
|
||||
from selenium.webdriver.common.by import By # noqa: E402
|
||||
from selenium.webdriver.common.keys import Keys # noqa: E402
|
||||
from selenium.webdriver.support import expected_conditions as EC # noqa: E402
|
||||
from selenium.webdriver.support.ui import WebDriverWait # noqa: E402
|
||||
|
||||
# Restore paths so other test modules aren't affected
|
||||
for _p in _src_paths:
|
||||
if _p not in sys.path:
|
||||
sys.path.append(_p)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def driver():
|
||||
"""Headless Chrome WebDriver, shared across tests in this module."""
|
||||
opts = Options()
|
||||
opts.add_argument("--headless=new")
|
||||
opts.add_argument("--no-sandbox")
|
||||
opts.add_argument("--disable-dev-shm-usage")
|
||||
opts.add_argument("--disable-gpu")
|
||||
opts.add_argument("--window-size=1280,900")
|
||||
|
||||
d = webdriver.Chrome(options=opts)
|
||||
d.implicitly_wait(5)
|
||||
yield d
|
||||
d.quit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _check_dashboard():
|
||||
"""Skip all tests if the dashboard isn't reachable."""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
r = httpx.get(f"{DASHBOARD_URL}/health", timeout=5)
|
||||
if r.status_code != 200:
|
||||
pytest.skip("Dashboard not healthy")
|
||||
except Exception:
|
||||
pytest.skip("Dashboard not reachable at " + DASHBOARD_URL)
|
||||
|
||||
|
||||
def _load_dashboard(driver):
|
||||
"""Navigate to dashboard and wait for Timmy panel to load."""
|
||||
driver.get(DASHBOARD_URL)
|
||||
WebDriverWait(driver, 15).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//*[contains(text(), 'TIMMY INTERFACE')]")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_sidebar(driver):
|
||||
"""Wait for the agent sidebar to finish its HTMX load."""
|
||||
WebDriverWait(driver, 15).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//*[contains(text(), 'SWARM AGENTS')]")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _has_registered_agents(driver):
|
||||
"""Check if there are any registered agent cards in the sidebar."""
|
||||
cards = driver.find_elements(By.CSS_SELECTOR, ".mc-agent-card")
|
||||
return len(cards) > 0
|
||||
|
||||
|
||||
def _send_chat_and_wait(driver, message):
|
||||
"""Send a chat message and wait for the NEW agent response.
|
||||
|
||||
Returns the number of agent messages before and after sending.
|
||||
"""
|
||||
existing = len(driver.find_elements(By.CSS_SELECTOR, ".chat-message.agent"))
|
||||
|
||||
inp = driver.find_element(By.CSS_SELECTOR, "input[name='message']")
|
||||
inp.send_keys(message)
|
||||
inp.send_keys(Keys.RETURN)
|
||||
|
||||
# Wait for a NEW agent response (not one from a prior test)
|
||||
WebDriverWait(driver, 30).until(
|
||||
lambda d: len(d.find_elements(By.CSS_SELECTOR, ".chat-message.agent")) > existing
|
||||
)
|
||||
|
||||
return existing
|
||||
|
||||
|
||||
# ── Page load tests ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPageLoad:
|
||||
"""Dashboard loads and shows expected structure."""
|
||||
|
||||
def test_homepage_loads(self, driver):
|
||||
driver.get(DASHBOARD_URL)
|
||||
assert driver.title != ""
|
||||
|
||||
def test_header_visible(self, driver):
|
||||
_load_dashboard(driver)
|
||||
header = driver.find_element(By.CSS_SELECTOR, ".mc-header, header, nav")
|
||||
assert header.is_displayed()
|
||||
|
||||
def test_sidebar_loads(self, driver):
|
||||
_load_dashboard(driver)
|
||||
_wait_for_sidebar(driver)
|
||||
|
||||
def test_timmy_panel_loads(self, driver):
|
||||
_load_dashboard(driver)
|
||||
|
||||
def test_chat_input_exists(self, driver):
|
||||
_load_dashboard(driver)
|
||||
inp = driver.find_element(By.CSS_SELECTOR, "input[name='message']")
|
||||
assert inp.is_displayed()
|
||||
assert "timmy" in inp.get_attribute("placeholder").lower()
|
||||
|
||||
def test_send_button_exists(self, driver):
|
||||
_load_dashboard(driver)
|
||||
btn = driver.find_element(By.CSS_SELECTOR, "button.mc-btn-send")
|
||||
assert btn.is_displayed()
|
||||
assert "SEND" in btn.text
|
||||
|
||||
def test_health_panel_loads(self, driver):
|
||||
_load_dashboard(driver)
|
||||
WebDriverWait(driver, 10).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//*[contains(text(), 'SYSTEM HEALTH')]")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ── Chat interaction tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestChatInteraction:
|
||||
"""Send a single message and verify all chat-related behaviors at once.
|
||||
|
||||
We only send ONE message to avoid spamming Ollama and crashing the browser.
|
||||
"""
|
||||
|
||||
def test_chat_roundtrip(self, driver):
|
||||
"""Full chat roundtrip: send message, get response, input clears, chat scrolls."""
|
||||
_load_dashboard(driver)
|
||||
|
||||
# Wait for any initial HTMX requests (history load) to settle
|
||||
time.sleep(2)
|
||||
|
||||
existing_agents = len(driver.find_elements(By.CSS_SELECTOR, ".chat-message.agent"))
|
||||
|
||||
inp = driver.find_element(By.CSS_SELECTOR, "input[name='message']")
|
||||
inp.send_keys("hello from selenium")
|
||||
inp.send_keys(Keys.RETURN)
|
||||
|
||||
# 1. User bubble appears immediately
|
||||
WebDriverWait(driver, 5).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.CSS_SELECTOR, ".chat-message.user")
|
||||
)
|
||||
)
|
||||
|
||||
# 2. Agent response arrives
|
||||
WebDriverWait(driver, 30).until(
|
||||
lambda d: len(d.find_elements(By.CSS_SELECTOR, ".chat-message.agent")) > existing_agents
|
||||
)
|
||||
|
||||
# 3. Input cleared (regression test)
|
||||
time.sleep(0.5)
|
||||
inp = driver.find_element(By.CSS_SELECTOR, "input[name='message']")
|
||||
assert inp.get_attribute("value") == "", "Input should be empty after sending"
|
||||
|
||||
# 4. Chat scrolled to bottom (regression test)
|
||||
chat_log = driver.find_element(By.ID, "chat-log")
|
||||
scroll_top = driver.execute_script("return arguments[0].scrollTop", chat_log)
|
||||
scroll_height = driver.execute_script("return arguments[0].scrollHeight", chat_log)
|
||||
client_height = driver.execute_script("return arguments[0].clientHeight", chat_log)
|
||||
|
||||
if scroll_height > client_height:
|
||||
gap = scroll_height - scroll_top - client_height
|
||||
assert gap < 50, f"Chat not scrolled to bottom (gap: {gap}px)"
|
||||
|
||||
|
||||
# ── Task panel tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTaskPanel:
|
||||
"""Task creation panel works correctly."""
|
||||
|
||||
def test_task_panel_via_url(self, driver):
|
||||
"""Task panel loads correctly when navigated to directly."""
|
||||
driver.get(f"{DASHBOARD_URL}/swarm/tasks/panel")
|
||||
WebDriverWait(driver, 10).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//*[contains(text(), 'CREATE TASK')]")
|
||||
)
|
||||
)
|
||||
|
||||
def test_task_panel_has_form(self, driver):
|
||||
"""Task creation panel has description and agent fields."""
|
||||
driver.get(f"{DASHBOARD_URL}/swarm/tasks/panel")
|
||||
WebDriverWait(driver, 10).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//*[contains(text(), 'CREATE TASK')]")
|
||||
)
|
||||
)
|
||||
|
||||
driver.find_element(By.CSS_SELECTOR, "textarea[name='description']")
|
||||
driver.find_element(By.CSS_SELECTOR, "select[name='agent_id']")
|
||||
|
||||
def test_task_button_on_agent_card(self, driver):
|
||||
"""If agents are registered, TASK button on agent card opens task panel."""
|
||||
_load_dashboard(driver)
|
||||
_wait_for_sidebar(driver)
|
||||
|
||||
if not _has_registered_agents(driver):
|
||||
pytest.skip("No agents registered — TASK button not available")
|
||||
|
||||
task_btn = driver.find_element(
|
||||
By.XPATH,
|
||||
"//div[contains(@class, 'mc-agent-card')]//button[contains(text(), 'TASK')]",
|
||||
)
|
||||
task_btn.click()
|
||||
|
||||
WebDriverWait(driver, 10).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//*[contains(text(), 'CREATE TASK')]")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ── Agent sidebar tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAgentSidebar:
|
||||
"""Agent sidebar displays correctly."""
|
||||
|
||||
def test_sidebar_header_shows(self, driver):
|
||||
_load_dashboard(driver)
|
||||
_wait_for_sidebar(driver)
|
||||
header = driver.find_element(
|
||||
By.XPATH, "//*[contains(text(), 'SWARM AGENTS')]"
|
||||
)
|
||||
assert header.is_displayed()
|
||||
|
||||
def test_sidebar_shows_status_when_agents_exist(self, driver):
|
||||
"""If agents are registered, cards show status dots."""
|
||||
_load_dashboard(driver)
|
||||
_wait_for_sidebar(driver)
|
||||
|
||||
if not _has_registered_agents(driver):
|
||||
pytest.skip("No agents registered — skipping card test")
|
||||
|
||||
cards = driver.find_elements(By.CSS_SELECTOR, ".mc-agent-card")
|
||||
for card in cards:
|
||||
dots = card.find_elements(By.CSS_SELECTOR, ".status-dot")
|
||||
assert len(dots) >= 1, "Agent card should show a status dot"
|
||||
|
||||
def test_no_agents_fallback(self, driver):
|
||||
"""When no agents registered, sidebar shows fallback message."""
|
||||
_load_dashboard(driver)
|
||||
_wait_for_sidebar(driver)
|
||||
|
||||
if _has_registered_agents(driver):
|
||||
pytest.skip("Agents are registered — fallback not shown")
|
||||
|
||||
body = driver.find_element(By.CSS_SELECTOR, ".mc-sidebar").text
|
||||
assert "NO AGENTS REGISTERED" in body
|
||||
|
||||
|
||||
# ── Navigation tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNavigation:
|
||||
"""Basic navigation flows work end-to-end."""
|
||||
|
||||
def test_clear_chat_button(self, driver):
|
||||
_load_dashboard(driver)
|
||||
clear_btn = driver.find_element(By.XPATH, "//button[contains(text(), 'CLEAR')]")
|
||||
assert clear_btn.is_displayed()
|
||||
|
||||
def test_health_endpoint_returns_200(self, driver):
|
||||
driver.get(f"{DASHBOARD_URL}/health")
|
||||
assert "ok" in driver.page_source
|
||||
|
||||
def test_nav_links_visible(self, driver):
|
||||
_load_dashboard(driver)
|
||||
links = driver.find_elements(By.CSS_SELECTOR, ".mc-desktop-nav .mc-test-link")
|
||||
assert len(links) >= 3, "Navigation should have multiple links"
|
||||
@@ -37,7 +37,9 @@ def test_create_timmy_uses_llama32():
|
||||
from timmy.agent import create_timmy
|
||||
create_timmy()
|
||||
|
||||
MockOllama.assert_called_once_with(id="llama3.2")
|
||||
MockOllama.assert_called_once()
|
||||
kwargs = MockOllama.call_args.kwargs
|
||||
assert kwargs["id"] == "llama3.2"
|
||||
|
||||
|
||||
def test_create_timmy_history_config():
|
||||
@@ -79,6 +81,46 @@ def test_create_timmy_embeds_system_prompt():
|
||||
assert kwargs["description"] == TIMMY_SYSTEM_PROMPT
|
||||
|
||||
|
||||
# ── Ollama host regression (container connectivity) ─────────────────────────
|
||||
|
||||
def test_create_timmy_passes_ollama_url_to_model():
|
||||
"""Regression: Ollama model must receive settings.ollama_url as host.
|
||||
|
||||
Without this, containers default to localhost:11434 which is unreachable
|
||||
when Ollama runs on the Docker host.
|
||||
"""
|
||||
with patch("timmy.agent.Agent"), \
|
||||
patch("timmy.agent.Ollama") as MockOllama, \
|
||||
patch("timmy.agent.SqliteDb"):
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
create_timmy()
|
||||
|
||||
kwargs = MockOllama.call_args.kwargs
|
||||
assert "host" in kwargs, "Ollama() must receive host= parameter"
|
||||
assert kwargs["host"] == "http://localhost:11434" # default from config
|
||||
|
||||
|
||||
def test_create_timmy_respects_custom_ollama_url():
|
||||
"""Ollama host should follow OLLAMA_URL when overridden in config."""
|
||||
custom_url = "http://host.docker.internal:11434"
|
||||
with patch("timmy.agent.Agent"), \
|
||||
patch("timmy.agent.Ollama") as MockOllama, \
|
||||
patch("timmy.agent.SqliteDb"), \
|
||||
patch("timmy.agent.settings") as mock_settings:
|
||||
|
||||
mock_settings.ollama_model = "llama3.2"
|
||||
mock_settings.ollama_url = custom_url
|
||||
mock_settings.timmy_model_backend = "ollama"
|
||||
mock_settings.airllm_model_size = "70b"
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
create_timmy()
|
||||
|
||||
kwargs = MockOllama.call_args.kwargs
|
||||
assert kwargs["host"] == custom_url
|
||||
|
||||
|
||||
# ── AirLLM path ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_timmy_airllm_returns_airllm_agent():
|
||||
|
||||
@@ -192,17 +192,11 @@ class TestSwarmUIPartials:
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
def test_task_panel_route_shadowed(self, client):
|
||||
"""The /swarm/tasks/panel route is shadowed by /swarm/tasks/{task_id}.
|
||||
|
||||
FastAPI matches the dynamic {task_id} route first, so "panel" is
|
||||
treated as a task_id lookup, returning JSON with an error.
|
||||
This documents the current behavior (a routing order issue).
|
||||
"""
|
||||
def test_task_panel_route_returns_html(self, client):
|
||||
"""The /swarm/tasks/panel route must return HTML, not be shadowed by {task_id}."""
|
||||
response = client.get("/swarm/tasks/panel")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
def test_direct_assign_with_agent(self, client):
|
||||
spawn_resp = client.post("/swarm/spawn", data={"name": "Worker"})
|
||||
|
||||
Reference in New Issue
Block a user