Restructurare Ralph QC loop pe smart gate dispatcher tag-driven (în loc de 5 faze fixe), DAG dependsOn cu propagare blocked, retry guard 3-strike, rate limit detection, plus dashboard live cu polling 5s. Changes: - tools/ralph_prd_generator.py: parametru optional final_plan_path; când e furnizat, invocă Claude Opus pe final-plan.md pentru extragere user stories cu schema extinsă (tags, dependsOn, acceptanceCriteria 3-5). Backward compat păstrat — fără final_plan_path, fallback la heuristic-ul vechi. - tools/ralph/prd-template.json: schema W3 (tags[], dependsOn[], retries, failed, blocked, failureReason, requiresDesignReview). - tools/ralph/prompt.md: 4 faze (impl, base quality, smart gates, commit) + dispatcher pe story.tags. Tags vide → run-all-gates fallback (safe default). - tools/ralph_dag.py (nou): tag validation heuristic anti-silent-regression (force ui dacă diff atinge .vue/.tsx/.html/.css/.scss; force db pentru migrations sau .sql; force vercel dacă există vercel.json) + topological sort cu blocked propagation + atomic prd.json updates. - tools/ralph/ralph.sh: --max-turns 30, DAG-aware story selection, retry counter cu auto-fail la 3, rate limit detection (sleep 30min + 1 retry), CLI subcommands prin tools/ralph_dag.py helper. - dashboard/handlers/ralph.py (nou): /api/ralph/status + /<slug>/log + /prd + /stop. Defensive vs corrupt prd.json. Sandbox-ed PID kill. - dashboard/ralph.html (nou): live cards 3/2/1 col responsive, polling 5s, drawer pentru log/PRD viewer, status colors (--status-running/blocked/ failed/complete declarate inline), Lucide icons cu aria-labels. - dashboard/api.py: mount /api/ralph/* (GET status/log/prd, POST stop). - tests/: 72 teste noi (smart gates, DAG, retry, dashboard endpoint). Note arhitecturale: - Polling 5s ales peste SSE/WebSocket (suficient pentru iter Ralph 8-15min) - Tag validation rulează POST-iter pe diff git pentru anti-silent-regression - Rate limit retry: 1 dată per rulare, apoi mark failed=rate_limited Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
7.9 KiB
Python
210 lines
7.9 KiB
Python
"""Tests for /api/ralph/* endpoints (dashboard live).
|
|
|
|
Acoperă:
|
|
- /api/ralph/status: list cards cu state + count + fetchedAt
|
|
- /api/ralph/<slug>/log: tail progress.txt
|
|
- /api/ralph/<slug>/prd: full prd.json
|
|
- _ralph_validate_slug: path traversal protection
|
|
- corrupt prd.json: graceful error (status='error', not 500)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
DASH = PROJECT_ROOT / "dashboard"
|
|
if str(DASH) not in sys.path:
|
|
sys.path.insert(0, str(DASH))
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def ralph_module():
|
|
from handlers import ralph as _r # type: ignore
|
|
return _r
|
|
|
|
|
|
@pytest.fixture
|
|
def handler(ralph_module, tmp_path, monkeypatch):
|
|
"""Build a stubbed handler with a temp WORKSPACE_DIR."""
|
|
import constants # type: ignore
|
|
|
|
# Re-route WORKSPACE_DIR la tmp pentru izolare
|
|
monkeypatch.setattr(constants, "WORKSPACE_DIR", tmp_path)
|
|
|
|
class _Stub(ralph_module.RalphHandlers):
|
|
def __init__(self):
|
|
self.captured = None
|
|
self.captured_code = None
|
|
self.path = "/api/ralph/status"
|
|
|
|
def send_json(self, data, code=200):
|
|
self.captured = data
|
|
self.captured_code = code
|
|
|
|
def send_error(self, code):
|
|
self.captured = {"error_code": code}
|
|
self.captured_code = code
|
|
|
|
return _Stub()
|
|
|
|
|
|
def _make_ralph_project(workspace: Path, slug: str, stories: list, progress: str = "init"):
|
|
"""Create a fake ralph project under workspace/<slug>/scripts/ralph/."""
|
|
ralph_dir = workspace / slug / "scripts" / "ralph"
|
|
ralph_dir.mkdir(parents=True, exist_ok=True)
|
|
(ralph_dir / "prd.json").write_text(json.dumps({
|
|
"projectName": slug,
|
|
"branchName": f"ralph/{slug}",
|
|
"userStories": stories,
|
|
}), encoding="utf-8")
|
|
(ralph_dir / "progress.txt").write_text(progress, encoding="utf-8")
|
|
return ralph_dir
|
|
|
|
|
|
# ── /api/ralph/status ──────────────────────────────────────────
|
|
|
|
|
|
class TestStatus:
|
|
def test_empty_workspace_returns_empty(self, handler):
|
|
handler.handle_ralph_status()
|
|
assert handler.captured_code == 200
|
|
assert handler.captured["projects"] == []
|
|
assert "fetchedAt" in handler.captured
|
|
|
|
def test_status_skips_non_ralph_projects(self, handler, tmp_path):
|
|
# Create a project WITHOUT scripts/ralph
|
|
(tmp_path / "regular-proj").mkdir()
|
|
handler.handle_ralph_status()
|
|
assert handler.captured["projects"] == []
|
|
|
|
def test_status_lists_ralph_projects(self, handler, tmp_path):
|
|
_make_ralph_project(tmp_path, "proj-a", [
|
|
{"id": "US-001", "title": "a", "priority": 10, "passes": True,
|
|
"failed": False, "blocked": False, "retries": 0, "tags": []},
|
|
{"id": "US-002", "title": "b", "priority": 20, "passes": False,
|
|
"failed": False, "blocked": False, "retries": 0, "tags": ["ui"]},
|
|
])
|
|
handler.handle_ralph_status()
|
|
projects = handler.captured["projects"]
|
|
assert len(projects) == 1
|
|
p = projects[0]
|
|
assert p["slug"] == "proj-a"
|
|
assert p["storiesTotal"] == 2
|
|
assert p["storiesComplete"] == 1
|
|
assert p["storiesFailed"] == 0
|
|
assert p["storiesBlocked"] == 0
|
|
assert p["status"] == "idle" # not running (no .ralph.pid)
|
|
|
|
def test_status_corrupt_prd_returns_error_not_500(self, handler, tmp_path):
|
|
ralph_dir = tmp_path / "broken" / "scripts" / "ralph"
|
|
ralph_dir.mkdir(parents=True)
|
|
(ralph_dir / "prd.json").write_text("{not valid json", encoding="utf-8")
|
|
handler.handle_ralph_status()
|
|
assert handler.captured_code == 200
|
|
assert any(p.get("status") == "error" for p in handler.captured["projects"])
|
|
|
|
def test_status_count_matches_projects(self, handler, tmp_path):
|
|
_make_ralph_project(tmp_path, "p1", [])
|
|
_make_ralph_project(tmp_path, "p2", [])
|
|
handler.handle_ralph_status()
|
|
assert handler.captured["count"] == 2
|
|
|
|
def test_complete_status_when_all_pass(self, handler, tmp_path):
|
|
_make_ralph_project(tmp_path, "donezo", [
|
|
{"id": "US-001", "passes": True, "failed": False, "blocked": False,
|
|
"retries": 0, "tags": [], "title": "x", "priority": 10},
|
|
])
|
|
handler.handle_ralph_status()
|
|
p = handler.captured["projects"][0]
|
|
assert p["status"] == "complete"
|
|
|
|
def test_failed_status_propagation(self, handler, tmp_path):
|
|
_make_ralph_project(tmp_path, "broken-proj", [
|
|
{"id": "US-001", "passes": False, "failed": True, "blocked": False,
|
|
"retries": 3, "tags": [], "title": "x", "priority": 10,
|
|
"failureReason": "max_retries"},
|
|
])
|
|
handler.handle_ralph_status()
|
|
p = handler.captured["projects"][0]
|
|
assert p["status"] == "failed"
|
|
assert p["storiesFailed"] == 1
|
|
|
|
|
|
# ── /api/ralph/<slug>/log ──────────────────────────────────────
|
|
|
|
|
|
class TestLog:
|
|
def test_log_returns_progress_lines(self, handler, tmp_path):
|
|
_make_ralph_project(tmp_path, "p1", [], progress="line1\nline2\nline3")
|
|
handler.path = "/api/ralph/p1/log"
|
|
handler.handle_ralph_log("p1")
|
|
assert handler.captured_code == 200
|
|
assert handler.captured["lines"] == ["line1", "line2", "line3"]
|
|
assert handler.captured["total"] == 3
|
|
|
|
def test_log_invalid_slug_400(self, handler):
|
|
handler.handle_ralph_log("../etc/passwd")
|
|
assert handler.captured_code == 400
|
|
|
|
def test_log_path_traversal_blocked(self, handler):
|
|
handler.handle_ralph_log("..")
|
|
assert handler.captured_code == 400
|
|
|
|
def test_log_missing_progress_returns_empty(self, handler, tmp_path):
|
|
ralph_dir = tmp_path / "noprogress" / "scripts" / "ralph"
|
|
ralph_dir.mkdir(parents=True)
|
|
(ralph_dir / "prd.json").write_text("{}") # no progress.txt
|
|
handler.path = "/api/ralph/noprogress/log"
|
|
handler.handle_ralph_log("noprogress")
|
|
assert handler.captured_code == 200
|
|
assert handler.captured["lines"] == []
|
|
|
|
|
|
# ── /api/ralph/<slug>/prd ──────────────────────────────────────
|
|
|
|
|
|
class TestPrd:
|
|
def test_prd_returns_full_json(self, handler, tmp_path):
|
|
stories = [{"id": "US-001", "passes": False, "title": "t", "priority": 10}]
|
|
_make_ralph_project(tmp_path, "p1", stories)
|
|
handler.handle_ralph_prd("p1")
|
|
assert handler.captured_code == 200
|
|
assert handler.captured["projectName"] == "p1"
|
|
assert len(handler.captured["userStories"]) == 1
|
|
|
|
def test_prd_404_when_missing(self, handler, tmp_path):
|
|
(tmp_path / "ghost").mkdir() # exists, but no prd.json
|
|
handler.handle_ralph_prd("ghost")
|
|
assert handler.captured_code == 404
|
|
|
|
def test_prd_invalid_slug_400(self, handler):
|
|
handler.handle_ralph_prd("/etc/passwd")
|
|
assert handler.captured_code == 400
|
|
|
|
|
|
# ── _ralph_validate_slug ───────────────────────────────────────
|
|
|
|
|
|
class TestValidateSlug:
|
|
def test_valid_slug_returns_path(self, handler, tmp_path):
|
|
(tmp_path / "good-slug").mkdir()
|
|
result = handler._ralph_validate_slug("good-slug")
|
|
assert result is not None
|
|
assert result.name == "good-slug"
|
|
|
|
def test_slash_rejected(self, handler):
|
|
assert handler._ralph_validate_slug("a/b") is None
|
|
|
|
def test_dotdot_rejected(self, handler):
|
|
assert handler._ralph_validate_slug("..") is None
|
|
|
|
def test_empty_rejected(self, handler):
|
|
assert handler._ralph_validate_slug("") is None
|
|
|
|
def test_nonexistent_returns_none(self, handler):
|
|
assert handler._ralph_validate_slug("does-not-exist") is None
|