Merge branch 'ralph/dashboard-realtime' — SSE realtime + story rollback
Server-Sent Events (TODO P3): - GET /api/ralph/stream — signature-based change detection (poll FS 2s, emit doar la diff), heartbeat 30s, X-Accel-Buffering:no - HTTPServer → ThreadingHTTPServer (altfel SSE blochează toate endpoint-urile) - ralph.html: EventSource cu fallback permanent la polling 5s când CLOSED. Badge: 🟢 Live / ⏱ Polling / Offline Story rollback (TODO P3): - POST /api/ralph/<slug>/rollback — git revert --no-edit HEAD; fallback git reset --hard HEAD~1 doar la conflict - Decrementează passes pe ultima story complete; clears failed/blocked/retries (atomic temp+rename) - Slug strict regex ^[A-Za-z0-9_-]{1,64}$ + reject path traversal explicit - Buton ↩️ pe card-uri running; confirm dialog înainte de execuție - Response: {success, message, reverted_commit, story_reverted, method} Tests: 39/39 pe test_dashboard_ralph_endpoint (era 19; +20 cazuri noi). # Conflicts: # dashboard/api.py # dashboard/handlers/ralph.py
This commit is contained in:
@@ -255,3 +255,217 @@ class TestValidateSlug:
|
||||
|
||||
def test_nonexistent_returns_none(self, handler):
|
||||
assert handler._ralph_validate_slug("does-not-exist") is None
|
||||
|
||||
def test_underscore_allowed(self, handler, tmp_path):
|
||||
(tmp_path / "snake_case_slug").mkdir()
|
||||
result = handler._ralph_validate_slug("snake_case_slug")
|
||||
assert result is not None
|
||||
|
||||
def test_too_long_rejected(self, handler):
|
||||
assert handler._ralph_validate_slug("a" * 65) is None
|
||||
|
||||
def test_special_chars_rejected(self, handler):
|
||||
# Punctuaţie / spaţii / shell metachars — toate respinse de regex
|
||||
for bad in ("a b", "a;b", "a$b", "a.b", "a&b", "a|b", "a%2E"):
|
||||
assert handler._ralph_validate_slug(bad) is None, bad
|
||||
|
||||
def test_backslash_rejected(self, handler):
|
||||
assert handler._ralph_validate_slug("a\\b") is None
|
||||
|
||||
|
||||
# ── _ralph_collect_status / _ralph_signature (SSE helpers) ────
|
||||
|
||||
|
||||
class TestCollectAndSignature:
|
||||
def test_collect_empty_when_no_workspace(self, handler):
|
||||
snap = handler._ralph_collect_status()
|
||||
assert snap == {"projects": [], "fetchedAt": snap["fetchedAt"], "count": 0}
|
||||
|
||||
def test_collect_lists_projects(self, handler, tmp_path):
|
||||
_make_ralph_project(tmp_path, "proj-x", [
|
||||
{"id": "US-001", "passes": True, "failed": False, "blocked": False,
|
||||
"retries": 0, "tags": [], "title": "x", "priority": 10},
|
||||
])
|
||||
snap = handler._ralph_collect_status()
|
||||
assert snap["count"] == 1
|
||||
assert snap["projects"][0]["slug"] == "proj-x"
|
||||
|
||||
def test_signature_stable_when_unchanged(self, handler, tmp_path):
|
||||
_make_ralph_project(tmp_path, "p1", [])
|
||||
snap1 = handler._ralph_collect_status()
|
||||
snap2 = handler._ralph_collect_status()
|
||||
# fetchedAt diferă — semnătura ignoră asta intenţionat
|
||||
assert handler._ralph_signature(snap1) == handler._ralph_signature(snap2)
|
||||
|
||||
def test_signature_changes_when_project_added(self, handler, tmp_path):
|
||||
_make_ralph_project(tmp_path, "p1", [])
|
||||
sig1 = handler._ralph_signature(handler._ralph_collect_status())
|
||||
_make_ralph_project(tmp_path, "p2", [])
|
||||
sig2 = handler._ralph_signature(handler._ralph_collect_status())
|
||||
assert sig1 != sig2
|
||||
|
||||
def test_signature_changes_when_passes_changes(self, handler, tmp_path):
|
||||
_make_ralph_project(tmp_path, "p1", [
|
||||
{"id": "US-001", "passes": False, "failed": False, "blocked": False,
|
||||
"retries": 0, "tags": [], "title": "a", "priority": 10},
|
||||
])
|
||||
sig1 = handler._ralph_signature(handler._ralph_collect_status())
|
||||
# mutăm story la passes=True
|
||||
ralph_dir = tmp_path / "p1" / "scripts" / "ralph"
|
||||
prd = json.loads((ralph_dir / "prd.json").read_text())
|
||||
prd["userStories"][0]["passes"] = True
|
||||
(ralph_dir / "prd.json").write_text(json.dumps(prd))
|
||||
sig2 = handler._ralph_signature(handler._ralph_collect_status())
|
||||
assert sig1 != sig2
|
||||
|
||||
|
||||
# ── /api/ralph/<slug>/rollback ─────────────────────────────────
|
||||
|
||||
|
||||
def _git(cmd: list[str], cwd):
|
||||
"""Run a git subcommand for test setup; raise if it fails."""
|
||||
import subprocess
|
||||
return subprocess.run(
|
||||
["git"] + cmd, cwd=str(cwd), check=True,
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
|
||||
def _init_repo_with_two_commits(project_dir):
|
||||
"""Create a real git repo with two commits — needed for revert/reset tests."""
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
_git(["init", "-q", "-b", "main"], project_dir)
|
||||
_git(["config", "user.email", "test@example.com"], project_dir)
|
||||
_git(["config", "user.name", "Test"], project_dir)
|
||||
_git(["config", "commit.gpgsign", "false"], project_dir)
|
||||
(project_dir / "README.md").write_text("first")
|
||||
_git(["add", "README.md"], project_dir)
|
||||
_git(["commit", "-q", "-m", "first"], project_dir)
|
||||
(project_dir / "feature.txt").write_text("second commit content")
|
||||
_git(["add", "feature.txt"], project_dir)
|
||||
_git(["commit", "-q", "-m", "second"], project_dir)
|
||||
|
||||
|
||||
class TestRollback:
|
||||
def test_invalid_slug_400(self, handler):
|
||||
handler.handle_ralph_rollback("../etc/passwd")
|
||||
assert handler.captured_code == 400
|
||||
assert handler.captured["success"] is False
|
||||
|
||||
def test_path_traversal_blocked(self, handler):
|
||||
handler.handle_ralph_rollback("..")
|
||||
assert handler.captured_code == 400
|
||||
|
||||
def test_not_a_git_repo_400(self, handler, tmp_path):
|
||||
# Project există dar nu e git repo
|
||||
_make_ralph_project(tmp_path, "no-git", [])
|
||||
handler.handle_ralph_rollback("no-git")
|
||||
assert handler.captured_code == 400
|
||||
assert "not a git" in handler.captured["message"].lower()
|
||||
|
||||
def test_revert_success_with_story_decrement(self, handler, tmp_path):
|
||||
slug = "revert-ok"
|
||||
_make_ralph_project(tmp_path, slug, [
|
||||
{"id": "US-001", "passes": True, "failed": False, "blocked": False,
|
||||
"retries": 0, "tags": [], "title": "first", "priority": 10},
|
||||
{"id": "US-002", "passes": True, "failed": False, "blocked": False,
|
||||
"retries": 1, "tags": [], "title": "second", "priority": 20},
|
||||
{"id": "US-003", "passes": False, "failed": False, "blocked": False,
|
||||
"retries": 0, "tags": [], "title": "third", "priority": 30},
|
||||
])
|
||||
_init_repo_with_two_commits(tmp_path / slug)
|
||||
head = _git(["rev-parse", "HEAD"], tmp_path / slug).stdout.strip()
|
||||
|
||||
handler.handle_ralph_rollback(slug)
|
||||
|
||||
assert handler.captured_code == 200, handler.captured
|
||||
assert handler.captured["success"] is True
|
||||
assert handler.captured["reverted_commit"] == head
|
||||
assert handler.captured["method"] == "revert"
|
||||
# ultima story trecută (US-002) trebuie marcată incompletă
|
||||
assert handler.captured["story_reverted"] == "US-002"
|
||||
|
||||
# Verify atomic write efect: prd.json reflectă passes=False pe US-002
|
||||
prd = json.loads(
|
||||
(tmp_path / slug / "scripts" / "ralph" / "prd.json").read_text()
|
||||
)
|
||||
assert prd["userStories"][1]["id"] == "US-002"
|
||||
assert prd["userStories"][1]["passes"] is False
|
||||
assert prd["userStories"][1]["retries"] == 0
|
||||
# US-001 rămâne neatins
|
||||
assert prd["userStories"][0]["passes"] is True
|
||||
|
||||
# Verify git history: HEAD should be a new revert commit (not the old HEAD)
|
||||
new_head = _git(["rev-parse", "HEAD"], tmp_path / slug).stdout.strip()
|
||||
assert new_head != head
|
||||
|
||||
def test_revert_with_no_passing_stories_succeeds_without_decrement(self, handler, tmp_path):
|
||||
slug = "no-stories"
|
||||
_make_ralph_project(tmp_path, slug, [
|
||||
{"id": "US-001", "passes": False, "failed": False, "blocked": False,
|
||||
"retries": 0, "tags": [], "title": "a", "priority": 10},
|
||||
])
|
||||
_init_repo_with_two_commits(tmp_path / slug)
|
||||
handler.handle_ralph_rollback(slug)
|
||||
assert handler.captured_code == 200
|
||||
assert handler.captured["success"] is True
|
||||
# nimic de decrementat → story_reverted=None
|
||||
assert handler.captured["story_reverted"] is None
|
||||
|
||||
def test_response_shape_contract(self, handler, tmp_path):
|
||||
"""Răspunsul trebuie să aibă fix aceste keys ca să meargă în UI."""
|
||||
slug = "shape"
|
||||
_make_ralph_project(tmp_path, slug, [])
|
||||
_init_repo_with_two_commits(tmp_path / slug)
|
||||
handler.handle_ralph_rollback(slug)
|
||||
for k in ("success", "message", "reverted_commit", "story_reverted"):
|
||||
assert k in handler.captured, f"missing key: {k}"
|
||||
|
||||
def test_decrement_helper_atomic_write(self, handler, tmp_path):
|
||||
"""_ralph_decrement_last_pass: temp file nu trebuie să rămână în filesystem."""
|
||||
slug = "atomic"
|
||||
ralph_dir = _make_ralph_project(tmp_path, slug, [
|
||||
{"id": "US-001", "passes": True, "failed": False, "blocked": False,
|
||||
"retries": 0, "tags": [], "title": "x", "priority": 10},
|
||||
])
|
||||
result = handler._ralph_decrement_last_pass(tmp_path / slug)
|
||||
assert result == "US-001"
|
||||
# tmp file curăţat
|
||||
assert not (ralph_dir / "prd.json.tmp").exists()
|
||||
# passes=False persistat
|
||||
prd = json.loads((ralph_dir / "prd.json").read_text())
|
||||
assert prd["userStories"][0]["passes"] is False
|
||||
|
||||
def test_decrement_helper_no_passing_returns_none(self, handler, tmp_path):
|
||||
slug = "nothing-to-revert"
|
||||
_make_ralph_project(tmp_path, slug, [
|
||||
{"id": "US-001", "passes": False, "failed": False, "blocked": False,
|
||||
"retries": 0, "tags": [], "title": "x", "priority": 10},
|
||||
])
|
||||
result = handler._ralph_decrement_last_pass(tmp_path / slug)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── api.py routing ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApiRouting:
|
||||
"""Smoke test pentru ThreadingHTTPServer + dispatch /api/ralph/stream + rollback."""
|
||||
|
||||
def test_threading_http_server_in_use(self):
|
||||
import api # type: ignore
|
||||
# ThreadingHTTPServer este folosit pentru SSE non-blocking
|
||||
from http.server import ThreadingHTTPServer
|
||||
# Verify import doesn't reference deprecated HTTPServer at module level
|
||||
src = (PROJECT_ROOT / "dashboard" / "api.py").read_text()
|
||||
assert "ThreadingHTTPServer" in src
|
||||
|
||||
def test_stream_route_dispatches_handler(self):
|
||||
"""/api/ralph/stream trebuie să apeleze handle_ralph_stream."""
|
||||
src = (PROJECT_ROOT / "dashboard" / "api.py").read_text()
|
||||
assert "/api/ralph/stream" in src
|
||||
assert "handle_ralph_stream" in src
|
||||
|
||||
def test_rollback_route_dispatches_handler(self):
|
||||
src = (PROJECT_ROOT / "dashboard" / "api.py").read_text()
|
||||
assert "handle_ralph_rollback" in src
|
||||
|
||||
Reference in New Issue
Block a user