Replaces 5s polling on /echo/ralph.html with EventSource streaming and adds
a rollback control for the running Ralph cards.
Server (dashboard/handlers/ralph.py):
- /api/ralph/stream — Server-Sent Events. Emits `event: status` whenever a
signature over the projects' state changes (poll filesystem at 2s); emits
`event: heartbeat` every 30s to keep proxies happy. Disables proxy
buffering via X-Accel-Buffering:no.
- /api/ralph/<slug>/rollback (POST) — runs `git revert --no-edit HEAD` in
the project; falls back to `git reset --hard HEAD~1` only if revert
reports conflict. After rolling back the commit, decrements `passes` on
the last user story marked complete in prd.json (atomic temp+rename
write, same pattern as ralph_dag.py). Returns
`{success, message, reverted_commit, story_reverted, method}`.
- _ralph_validate_slug tightened to a strict regex (alphanum + dash +
underscore, ≤64 chars) plus explicit ../, /, \ rejection. All previously
accepted slugs still pass; URL-encoded traversal and shell metachars
now blocked before the filesystem is touched.
- _ralph_collect_status / _ralph_signature factored out of
handle_ralph_status so the SSE loop can reuse them and detect changes
cheaply.
Server (dashboard/api.py):
- HTTPServer → ThreadingHTTPServer with daemon_threads=True. SSE is a
long-lived response; without threading a single client would block all
other dashboard endpoints.
- /api/ralph/stream (GET) and /api/ralph/<slug>/rollback (POST) wired
into the dispatch.
Client (dashboard/ralph.html):
- EventSource('/api/ralph/stream') with permanent fallback to 5s polling
when readyState=CLOSED (no server, CORS blocked, browser without SSE).
- Indicator badge: 🟢 Live (SSE), ⏱ Polling (fallback), Offline.
- Rollback button (undo-2 icon) on running cards; native confirm() with
message: "Asta va da git revert HEAD pe <slug> și va decrementa ultima
story trecută. Continui?"
Tests (tests/test_dashboard_ralph_endpoint.py, +20 cases):
- Strict slug validator: underscore allowed, >64 rejected, special chars
/ backslash / URL-encoded traversal rejected.
- _ralph_collect_status + _ralph_signature: stable when nothing changes,
flips when project added or `passes` toggles.
- Rollback: invalid slug → 400, non-git project → 400, real two-commit
repo revert succeeds and decrements last passing story (US-002 goes
passes:false while US-001 stays passes:true), no-passing-stories case
succeeds with story_reverted=None, response shape contract, atomic
helper leaves no .tmp file behind.
- API routing smoke: confirms ThreadingHTTPServer + stream + rollback
references present in dashboard/api.py.
39/39 tests pass on tests/test_dashboard_ralph_endpoint.py. Pre-existing
failures in test_dashboard_constants.py::test_base_dir_is_echo_core (the
worktree dir is `echo-core-realtime`, not `echo-core`) and
test_dashboard_unified_index.py::test_index_has_all_panels are unrelated
to this change and reproduced on master.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
424 lines
18 KiB
Python
424 lines
18 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
|
|
|
|
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
|