From 29292cfb84caf96fad4f80e1a57466b56f74c511 Mon Sep 17 00:00:00 2001 From: Alexander Payne Date: Wed, 25 Feb 2026 07:20:56 -0500 Subject: [PATCH] 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 --- Dockerfile | 2 +- Makefile | 39 ++- docker-compose.dev.yml | 23 ++ docker-compose.yml | 2 - pyproject.toml | 1 + src/creative/assembler.py | 22 +- src/dashboard/routes/swarm.py | 90 ++--- src/dashboard/templates/base.html | 2 +- .../templates/partials/agent_panel.html | 2 +- .../templates/partials/timmy_panel.html | 5 +- src/timmy/agent.py | 2 +- src/timmy/cli.py | 30 ++ static/style.css | 5 +- tests/functional/test_ui_selenium.py | 319 ++++++++++++++++++ tests/test_agent.py | 44 ++- tests/test_swarm_routes_functional.py | 12 +- 16 files changed, 533 insertions(+), 67 deletions(-) create mode 100644 docker-compose.dev.yml create mode 100644 tests/functional/test_ui_selenium.py diff --git a/Dockerfile b/Dockerfile index 224869ea..7efebd2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index 744bfaea..e0c81db1 100644 --- a/Makefile +++ b/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" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..11c8f66a --- /dev/null +++ b/docker-compose.dev.yml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 6fc1e1bc..8c229a83 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,6 @@ # make docker-down stop everything # make docker-logs tail logs -version: "3.9" - services: # ── Dashboard (coordinator + FastAPI) ────────────────────────────────────── diff --git a/pyproject.toml b/pyproject.toml index e753c283..aa2a35d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]" diff --git a/src/creative/assembler.py b/src/creative/assembler.py index a5d03525..c95910a0 100644 --- a/src/creative/assembler.py +++ b/src/creative/assembler.py @@ -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: diff --git a/src/dashboard/routes/swarm.py b/src/dashboard/routes/swarm.py index dafd0b1d..b10a0d7e 100644 --- a/src/dashboard/routes/swarm.py +++ b/src/dashboard/routes/swarm.py @@ -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, - }, - ) diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index 5a316a7d..d3449063 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -11,7 +11,7 @@ - + {% block extra_styles %}{% endblock %} diff --git a/src/dashboard/templates/partials/agent_panel.html b/src/dashboard/templates/partials/agent_panel.html index c8e4c3c8..0451d301 100644 --- a/src/dashboard/templates/partials/agent_panel.html +++ b/src/dashboard/templates/partials/agent_panel.html @@ -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"> + hx-swap="innerHTML" + hx-on::after-settle="scrollChat()">