Merge pull request #13 from zumayaaustin-creator/devin/1783552139-add-unit-tests
* Add unit test suite for server.py and scheduler.py Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Avoid URL substring pattern in CORS test to satisfy CodeQL Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: zumayaaustin <zumayaaustin@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
015df05509
|
|
@ -1,6 +1,10 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.egg-info/
|
||||
dist/
|
||||
node_modules/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
addopts = -q
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-r requirements.txt
|
||||
pytest>=8.0.0
|
||||
pytest-cov>=5.0.0
|
||||
httpx>=0.27.0
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"""Shared pytest fixtures for the Agentic OS test suite.
|
||||
|
||||
The application code (``server.py``) computes a number of module-level path
|
||||
constants from ``BASE_DIR`` at import time. To keep tests hermetic — no writes
|
||||
to the real repository — the fixtures below redirect every one of those
|
||||
constants at a temporary directory and rebuild the minimal folder layout the
|
||||
endpoints expect.
|
||||
"""
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_module(tmp_path, monkeypatch):
|
||||
"""Import ``server`` with all filesystem paths redirected to ``tmp_path``.
|
||||
|
||||
Returns the imported module with its path globals patched so tests can
|
||||
exercise endpoints without touching the real project directories.
|
||||
"""
|
||||
server = importlib.import_module("server")
|
||||
|
||||
base = tmp_path
|
||||
for sub in ["data", "data/kanban", "brain", "brain/journal", "audit",
|
||||
"skills", "scheduler/jobs", "registry", "standards",
|
||||
"prompts", "backups", "agents"]:
|
||||
(base / sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(server, "BASE_DIR", base)
|
||||
monkeypatch.setattr(server, "KANBAN_DIR", base / "data" / "kanban")
|
||||
monkeypatch.setattr(server, "GOALS_FILE", base / "data" / "goals.json")
|
||||
monkeypatch.setattr(server, "JOURNAL_DIR", base / "brain" / "journal")
|
||||
monkeypatch.setattr(server, "CHAT_HISTORY_FILE",
|
||||
base / "data" / "chat-history.json")
|
||||
monkeypatch.setattr(server, "_terminal_cwd", str(base))
|
||||
|
||||
return server
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_module):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TestClient(server_module.app) as c:
|
||||
yield c
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"""Unit tests for ``scheduler/scheduler.py``."""
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCHEDULER_DIR = REPO_ROOT / "scheduler"
|
||||
if str(SCHEDULER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCHEDULER_DIR))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scheduler_module(tmp_path, monkeypatch):
|
||||
sched = importlib.import_module("scheduler")
|
||||
base = tmp_path / "scheduler"
|
||||
(base / "jobs").mkdir(parents=True)
|
||||
(tmp_path / "audit").mkdir()
|
||||
monkeypatch.setattr(sched, "BASE_DIR", base)
|
||||
monkeypatch.setattr(sched, "JOBS_DIR", base / "jobs")
|
||||
return sched
|
||||
|
||||
|
||||
def test_run_skill_appends_audit(scheduler_module, capsys):
|
||||
scheduler_module.run_skill("heartbeat")
|
||||
audit_file = scheduler_module.BASE_DIR.parent / "audit" / "audit.log"
|
||||
entry = json.loads(audit_file.read_text().strip())
|
||||
assert entry["action"] == "scheduler_run"
|
||||
assert entry["skill"] == "heartbeat"
|
||||
assert "timestamp" in entry
|
||||
assert "Ran skill: heartbeat" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_load_jobs_registers_enabled(scheduler_module):
|
||||
(scheduler_module.JOBS_DIR / "hb.json").write_text(json.dumps({
|
||||
"id": "hb1", "name": "Heartbeat", "skill": "heartbeat",
|
||||
"cron": "*/5 * * * *", "enabled": True,
|
||||
}))
|
||||
sched = scheduler_module.BackgroundScheduler()
|
||||
scheduler_module.load_jobs(sched)
|
||||
jobs = sched.get_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].id == "hb1"
|
||||
assert jobs[0].name == "Heartbeat"
|
||||
|
||||
|
||||
def test_load_jobs_skips_disabled(scheduler_module):
|
||||
(scheduler_module.JOBS_DIR / "off.json").write_text(json.dumps({
|
||||
"id": "off1", "name": "Disabled", "skill": "x",
|
||||
"cron": "0 0 * * *", "enabled": False,
|
||||
}))
|
||||
sched = scheduler_module.BackgroundScheduler()
|
||||
scheduler_module.load_jobs(sched)
|
||||
assert sched.get_jobs() == []
|
||||
|
||||
|
||||
def test_load_jobs_falls_back_to_name_as_id(scheduler_module):
|
||||
(scheduler_module.JOBS_DIR / "noid.json").write_text(json.dumps({
|
||||
"name": "NoId", "skill": "x", "cron": "0 0 * * *", "enabled": True,
|
||||
}))
|
||||
sched = scheduler_module.BackgroundScheduler()
|
||||
scheduler_module.load_jobs(sched)
|
||||
assert sched.get_jobs()[0].id == "NoId"
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
"""Endpoint tests for ``server.py`` exercised through FastAPI's TestClient."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ─── Status / static ──────────────────────────────────────────────
|
||||
|
||||
def test_status_ok(client, server_module, monkeypatch):
|
||||
monkeypatch.setattr(server_module.shutil, "which", lambda name: None)
|
||||
r = client.get("/api/status")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "healthy"
|
||||
assert {a["name"] for a in body["agents"]} == {"opencode", "hermes", "gemini"}
|
||||
assert body["skills_count"] == 0
|
||||
|
||||
|
||||
def test_index_without_dashboard(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "Agentic OS" in r.text
|
||||
|
||||
|
||||
def test_favicon_endpoints(client):
|
||||
for path in ("/favicon.ico", "/favicon.svg"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("image/svg")
|
||||
|
||||
|
||||
# ─── Brain ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_brain_list_and_get_and_update(client, server_module):
|
||||
brain = server_module.BASE_DIR / "brain"
|
||||
(brain / "memory.md").write_text("remember this")
|
||||
|
||||
r = client.get("/api/brain")
|
||||
assert r.json()["memory.md"] == "remember this"
|
||||
|
||||
r = client.get("/api/brain/memory.md")
|
||||
assert r.json() == {"name": "memory.md", "content": "remember this"}
|
||||
|
||||
r = client.put("/api/brain/memory.md", json={"content": "new content"})
|
||||
assert r.status_code == 200
|
||||
assert (brain / "memory.md").read_text() == "new content"
|
||||
|
||||
|
||||
def test_brain_get_missing_404(client):
|
||||
assert client.get("/api/brain/ghost.md").status_code == 404
|
||||
|
||||
|
||||
# ─── Skills ────────────────────────────────────────────────────────
|
||||
|
||||
def _make_skill(server_module, name, skill_md="", eval_data=None, scores=None):
|
||||
d = server_module.BASE_DIR / "skills" / name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
if skill_md:
|
||||
(d / "SKILL.md").write_text(skill_md)
|
||||
if eval_data is not None:
|
||||
(d / "eval.json").write_text(json.dumps(eval_data))
|
||||
if scores is not None:
|
||||
(d / "score-history.json").write_text(json.dumps(scores))
|
||||
return d
|
||||
|
||||
|
||||
def test_list_skills(client, server_module):
|
||||
_make_skill(server_module, "code-review", skill_md="Review code",
|
||||
eval_data={"criteria": ["clarity"]}, scores=[{"score": 8}])
|
||||
_make_skill(server_module, "_template", skill_md="ignored")
|
||||
skills = client.get("/api/skills").json()
|
||||
names = [s["name"] for s in skills]
|
||||
assert "code-review" in names
|
||||
assert "_template" not in names
|
||||
cr = next(s for s in skills if s["name"] == "code-review")
|
||||
assert cr["eval_criteria"] == ["clarity"]
|
||||
assert cr["scores"] == [{"score": 8}]
|
||||
|
||||
|
||||
def test_get_skill_and_missing(client, server_module):
|
||||
_make_skill(server_module, "brainstorming", skill_md="Ideas",
|
||||
eval_data={"criteria": []}, scores=[{"score": 5}])
|
||||
r = client.get("/api/skills/brainstorming")
|
||||
assert r.json()["skill"] == "Ideas"
|
||||
assert client.get("/api/skills/nope").status_code == 404
|
||||
|
||||
|
||||
def test_get_skill_eval(client, server_module):
|
||||
_make_skill(server_module, "tdd-cycle", scores=[{"score": 7}])
|
||||
assert client.get("/api/skills/tdd-cycle/eval").json() == {"scores": [{"score": 7}]}
|
||||
assert client.get("/api/skills/other/eval").json() == {"scores": []}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,expected_agent", [
|
||||
("devops-audit", "opencode"),
|
||||
("research-synthesis", "gemini"),
|
||||
("generic-skill", "opencode"),
|
||||
])
|
||||
def test_run_skill_auto_routes(client, server_module, monkeypatch, name, expected_agent):
|
||||
_make_skill(server_module, name, skill_md="do stuff")
|
||||
captured = {}
|
||||
|
||||
def fake_exec(agent, prompt):
|
||||
captured["agent"] = agent
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(server_module, "execute_agent", fake_exec)
|
||||
r = client.post(f"/api/skills/{name}/run", json={"input": "go", "agent": "auto"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["agent"] == expected_agent
|
||||
assert captured["agent"] == expected_agent
|
||||
|
||||
|
||||
def test_run_skill_uses_skill_md_primary(client, server_module, monkeypatch):
|
||||
_make_skill(server_module, "meeting-notes", skill_md="Primary: hermes\nrest")
|
||||
monkeypatch.setattr(server_module, "execute_agent", lambda a, p: "ok")
|
||||
r = client.post("/api/skills/meeting-notes/run", json={"agent": "auto"})
|
||||
assert r.json()["agent"] == "hermes"
|
||||
|
||||
|
||||
def test_run_skill_missing_404(client, server_module, monkeypatch):
|
||||
monkeypatch.setattr(server_module, "execute_agent", lambda a, p: "ok")
|
||||
assert client.post("/api/skills/ghost/run", json={}).status_code == 404
|
||||
|
||||
|
||||
# ─── Scheduler jobs ────────────────────────────────────────────────
|
||||
|
||||
def test_scheduler_job_crud(client, server_module):
|
||||
assert client.get("/api/scheduler/jobs").json() == []
|
||||
created = client.post("/api/scheduler/jobs", json={
|
||||
"name": "nightly audit", "skill": "devops-audit", "cron": "0 0 * * *",
|
||||
}).json()
|
||||
assert created["name"] == "nightly audit"
|
||||
jobs = client.get("/api/scheduler/jobs").json()
|
||||
assert len(jobs) == 1
|
||||
# file name derives from the job name with spaces replaced.
|
||||
assert (server_module.BASE_DIR / "scheduler" / "jobs" / "nightly_audit.json").exists()
|
||||
|
||||
assert client.delete(f"/api/scheduler/jobs/{created['id']}").json() == {"status": "deleted"}
|
||||
assert client.get("/api/scheduler/jobs").json() == []
|
||||
|
||||
|
||||
def test_delete_missing_job_404(client):
|
||||
assert client.delete("/api/scheduler/jobs/deadbeef").status_code == 404
|
||||
|
||||
|
||||
# ─── Audit ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_audit_empty_and_limit(client, server_module):
|
||||
assert client.get("/api/audit").json() == {"entries": []}
|
||||
for i in range(5):
|
||||
server_module.append_audit({"action": "act", "n": i})
|
||||
entries = client.get("/api/audit?limit=2").json()["entries"]
|
||||
assert len(entries) == 2
|
||||
assert entries[-1]["n"] == 4
|
||||
|
||||
|
||||
# ─── Cost ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_cost_empty_then_record(client):
|
||||
assert client.get("/api/cost").json()["entries"] == []
|
||||
client.post("/api/cost/record", json={"agent": "gemini", "tokens": 10, "cost": 0.0, "model": "flash"})
|
||||
data = client.get("/api/cost").json()
|
||||
assert data["entries"][0]["agent"] == "gemini"
|
||||
assert data["entries"][0]["tokens"] == 10
|
||||
|
||||
|
||||
# ─── Plugins ───────────────────────────────────────────────────────
|
||||
|
||||
def test_plugins_install_flow(client):
|
||||
assert client.get("/api/plugins").json() == {"plugins": []}
|
||||
assert client.post("/api/plugins/install", json={"name": "cool-plugin"}).json()["status"] == "installed"
|
||||
assert client.post("/api/plugins/install", json={"name": "cool-plugin"}).json()["status"] == "already_installed"
|
||||
assert client.post("/api/plugins/install", json={"name": ""}).status_code == 400
|
||||
assert client.get("/api/plugins").json()["plugins"][0]["name"] == "cool-plugin"
|
||||
|
||||
|
||||
# ─── Settings ──────────────────────────────────────────────────────
|
||||
|
||||
def test_settings_get_empty_and_update_merges(client, server_module):
|
||||
assert client.get("/api/settings").json() == {}
|
||||
client.put("/api/settings", json={"settings": {"theme": "dark"}})
|
||||
client.put("/api/settings", json={"settings": {"port": 9000}})
|
||||
result = client.get("/api/settings").json()
|
||||
assert result == {"theme": "dark", "port": 9000}
|
||||
|
||||
|
||||
# ─── Standards ─────────────────────────────────────────────────────
|
||||
|
||||
def test_standards_list_and_discover(client, server_module):
|
||||
std = server_module.BASE_DIR / "standards"
|
||||
(std / "naming.md").write_text("use snake_case")
|
||||
(std / "index.yml").write_text("standards: [naming]")
|
||||
body = client.get("/api/standards").json()
|
||||
assert any(s["name"] == "naming" for s in body["standards"])
|
||||
assert "snake_case" in body["standards"][0]["content"]
|
||||
assert client.post("/api/standards/discover").json()["status"] == "discovery_started"
|
||||
|
||||
|
||||
# ─── Prompts ───────────────────────────────────────────────────────
|
||||
|
||||
def test_prompts_list(client, server_module):
|
||||
(server_module.BASE_DIR / "prompts" / "code-review.md").write_text("template body")
|
||||
assert client.get("/api/prompts").json()["code-review"] == "template body"
|
||||
|
||||
|
||||
# ─── Backups ───────────────────────────────────────────────────────
|
||||
|
||||
def test_backup_create_list_restore(client, server_module):
|
||||
(server_module.BASE_DIR / "brain" / "memory.md").write_text("data")
|
||||
created = client.post("/api/backup").json()
|
||||
assert created["status"] == "ok"
|
||||
listed = client.get("/api/backups").json()
|
||||
assert any(b["name"] == created["file"] for b in listed)
|
||||
assert client.post("/api/backup/restore", json={"file": created["file"]}).json()["status"] == "restored"
|
||||
|
||||
|
||||
def test_restore_missing_backup_404(client):
|
||||
assert client.post("/api/backup/restore", json={"file": "nope.tar.gz"}).status_code == 404
|
||||
|
||||
|
||||
# ─── Chat ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_chat_invalid_agent(client):
|
||||
assert client.post("/api/chat", json={"agent": "bogus", "message": "hi"}).status_code == 400
|
||||
|
||||
|
||||
def test_chat_records_history(client, server_module, monkeypatch):
|
||||
monkeypatch.setattr(server_module, "execute_agent", lambda a, m: "hello from agent")
|
||||
r = client.post("/api/chat", json={"agent": "Gemini", "message": "hi"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["response"]["content"] == "hello from agent"
|
||||
history = client.get("/api/chat/history").json()["messages"]
|
||||
assert history[0]["role"] == "user"
|
||||
assert history[1]["role"] == "assistant"
|
||||
|
||||
|
||||
# ─── Terminal ──────────────────────────────────────────────────────
|
||||
|
||||
def test_terminal_session_and_blank_command(client, server_module):
|
||||
assert client.get("/api/terminal/session").json()["cwd"]
|
||||
r = client.post("/api/terminal/run", json={"command": " "})
|
||||
assert r.json()["returncode"] == 0
|
||||
|
||||
|
||||
def test_terminal_cd_invalid_dir(client):
|
||||
r = client.post("/api/terminal/run", json={"command": "cd /this/does/not/exist"})
|
||||
assert r.json()["returncode"] == 1
|
||||
assert "no such directory" in r.json()["stderr"]
|
||||
|
||||
|
||||
def test_terminal_run_echo(client):
|
||||
r = client.post("/api/terminal/run", json={"command": "echo hello-term"})
|
||||
assert r.json()["returncode"] == 0
|
||||
assert "hello-term" in r.json()["stdout"]
|
||||
|
||||
|
||||
# ─── Goals ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_goals_crud(client, server_module):
|
||||
assert client.get("/api/goals").json() == {"goals": []}
|
||||
created = client.post("/api/goals", json={"title": "Ship v1", "description": "launch"}).json()
|
||||
gid = created["id"]
|
||||
assert created["status"] == "active"
|
||||
updated = client.put(f"/api/goals/{gid}", json={"progress": 50}).json()
|
||||
assert updated["progress"] == 50
|
||||
assert client.put("/api/goals/missing", json={"progress": 1}).status_code == 404
|
||||
assert client.delete(f"/api/goals/{gid}").json() == {"status": "deleted"}
|
||||
assert client.get("/api/goals").json() == {"goals": []}
|
||||
|
||||
|
||||
def test_goal_creation_syncs_active_projects(client, server_module):
|
||||
active = server_module.BASE_DIR / "brain" / "active-projects.md"
|
||||
active.write_text("# Projects\n")
|
||||
client.post("/api/goals", json={"title": "Docs", "description": "write docs"})
|
||||
assert "Docs" in active.read_text()
|
||||
|
||||
|
||||
# ─── Journal ───────────────────────────────────────────────────────
|
||||
|
||||
def test_journal_save_get_list_search(client, server_module):
|
||||
assert client.get("/api/journal/entries").json() == {"entries": []}
|
||||
client.put("/api/journal/entries/2026-07-08", json={"content": "Today I tested code"})
|
||||
assert client.get("/api/journal/entries/2026-07-08").json()["content"] == "Today I tested code"
|
||||
entries = client.get("/api/journal/entries").json()["entries"]
|
||||
assert entries[0]["date"] == "2026-07-08"
|
||||
found = client.get("/api/journal/search?q=tested").json()["results"]
|
||||
assert found[0]["date"] == "2026-07-08"
|
||||
assert client.get("/api/journal/search?q=").json() == {"results": []}
|
||||
|
||||
|
||||
# ─── Agent health ──────────────────────────────────────────────────
|
||||
|
||||
def test_agent_health(client, server_module, monkeypatch):
|
||||
monkeypatch.setattr(server_module.shutil, "which", lambda n: None)
|
||||
body = client.get("/api/agents/health").json()
|
||||
assert len(body["agents"]) == 3
|
||||
assert body["agents"][0]["success_rate"] == 100
|
||||
|
||||
|
||||
def test_agent_stats_valid_and_invalid(client, server_module, monkeypatch):
|
||||
monkeypatch.setattr(server_module.shutil, "which", lambda n: None)
|
||||
assert client.get("/api/agents/opencode/stats").json()["name"] == "opencode"
|
||||
assert client.get("/api/agents/nope/stats").status_code == 400
|
||||
|
||||
|
||||
def test_agent_health_refresh(client, server_module, monkeypatch):
|
||||
monkeypatch.setattr(server_module.shutil, "which", lambda n: None)
|
||||
assert len(client.post("/api/agents/health/refresh").json()["agents"]) == 3
|
||||
|
||||
|
||||
# ─── Smart router ──────────────────────────────────────────────────
|
||||
|
||||
def test_router_suggest_high_confidence(client):
|
||||
body = client.post("/api/router/suggest", json={"task": "deploy docker infra with terraform"}).json()
|
||||
assert body["suggested_agent"] == "opencode"
|
||||
assert body["confidence"] == "high"
|
||||
|
||||
|
||||
def test_router_suggest_low_confidence(client):
|
||||
body = client.post("/api/router/suggest", json={"task": "xyzzy"}).json()
|
||||
assert body["confidence"] == "low"
|
||||
|
||||
|
||||
def test_router_route_valid_and_invalid(client):
|
||||
assert client.post("/api/router/route", json={"task": "t", "agent": "Hermes"}).json()["status"] == "routed"
|
||||
assert client.post("/api/router/route", json={"task": "t", "agent": "bad"}).json()["status"] == "error"
|
||||
|
||||
|
||||
# ─── Analytics ─────────────────────────────────────────────────────
|
||||
|
||||
def test_skill_analytics(client, server_module):
|
||||
_make_skill(server_module, "code-review", scores=[{"score": 5}, {"score": 8}])
|
||||
_make_skill(server_module, "brainstorming", scores=[])
|
||||
body = client.get("/api/analytics/skills").json()["skills"]
|
||||
cr = next(s for s in body if s["name"] == "code-review")
|
||||
assert cr["total_runs"] == 2
|
||||
assert cr["avg_score"] == 6.5
|
||||
assert cr["trend"] == "up"
|
||||
|
||||
|
||||
def test_trend_analytics(client, server_module):
|
||||
_make_skill(server_module, "tdd-cycle", scores=[{"score": 3, "date": "d1"}])
|
||||
body = client.get("/api/analytics/trends").json()["trends"]
|
||||
assert body[0]["name"] == "tdd-cycle"
|
||||
assert body[0]["scores"] == [3]
|
||||
|
||||
|
||||
# ─── Session replay ────────────────────────────────────────────────
|
||||
|
||||
def test_sessions_list_empty(client, server_module, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path))
|
||||
assert client.get("/api/sessions/list").json() == {"sessions": []}
|
||||
|
||||
|
||||
def test_session_replay_not_found(client, server_module, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path))
|
||||
body = client.get("/api/sessions/some-id/replay").json()
|
||||
assert body["content"] == "Session log not found"
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
"""Unit tests for the pure helper functions in ``server.py``."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_read_file_missing_returns_empty(server_module, tmp_path):
|
||||
assert server_module.read_file(tmp_path / "nope.txt") == ""
|
||||
|
||||
|
||||
def test_read_write_file_roundtrip(server_module, tmp_path):
|
||||
target = tmp_path / "note.txt"
|
||||
assert server_module.write_file(target, "hello") is True
|
||||
assert server_module.read_file(target) == "hello"
|
||||
|
||||
|
||||
def test_list_dir_missing_returns_empty(server_module, tmp_path):
|
||||
assert server_module.list_dir(tmp_path / "absent") == []
|
||||
|
||||
|
||||
def test_list_dir_skips_hidden_and_sorts(server_module, tmp_path):
|
||||
d = tmp_path / "things"
|
||||
d.mkdir()
|
||||
(d / "b.txt").write_text("")
|
||||
(d / "a.txt").write_text("")
|
||||
(d / ".hidden").write_text("")
|
||||
assert server_module.list_dir(d) == ["a.txt", "b.txt"]
|
||||
|
||||
|
||||
def test_get_timestamp_is_iso_utc(server_module):
|
||||
ts = server_module.get_timestamp()
|
||||
# datetime.fromisoformat round-trips a valid ISO 8601 string.
|
||||
from datetime import datetime
|
||||
|
||||
parsed = datetime.fromisoformat(ts)
|
||||
assert parsed.tzinfo is not None
|
||||
|
||||
|
||||
def test_append_audit_writes_entry(server_module):
|
||||
server_module.append_audit({"action": "unit_test"})
|
||||
audit_file = server_module.BASE_DIR / "audit" / "audit.log"
|
||||
lines = audit_file.read_text().strip().splitlines()
|
||||
assert len(lines) == 1
|
||||
entry = json.loads(lines[0])
|
||||
assert entry["action"] == "unit_test"
|
||||
assert "timestamp" in entry
|
||||
assert len(entry["id"]) == 8
|
||||
|
||||
|
||||
def test_get_cors_origins_defaults(server_module, monkeypatch):
|
||||
monkeypatch.delenv("AGENTIC_OS_CORS_ORIGINS", raising=False)
|
||||
origins = server_module.get_cors_origins()
|
||||
assert "http://localhost:8080" in origins
|
||||
assert "http://127.0.0.1:8080" in origins
|
||||
|
||||
|
||||
def test_get_cors_origins_reads_port_from_settings(server_module, monkeypatch):
|
||||
monkeypatch.delenv("AGENTIC_OS_CORS_ORIGINS", raising=False)
|
||||
settings = server_module.BASE_DIR / "data" / "settings.json"
|
||||
settings.write_text(json.dumps({"dashboard": {"port": 9000}}))
|
||||
origins = server_module.get_cors_origins()
|
||||
assert "http://localhost:9000" in origins
|
||||
assert "http://127.0.0.1:9000" in origins
|
||||
|
||||
|
||||
def test_get_cors_origins_includes_env_extras(server_module, monkeypatch):
|
||||
monkeypatch.setenv("AGENTIC_OS_CORS_ORIGINS", "https://a.example, https://b.example ")
|
||||
origins = server_module.get_cors_origins()
|
||||
assert any(o == "https://a.example" for o in origins)
|
||||
assert any(o == "https://b.example" for o in origins)
|
||||
|
||||
|
||||
def test_get_cors_origins_bad_settings_falls_back(server_module):
|
||||
settings = server_module.BASE_DIR / "data" / "settings.json"
|
||||
settings.write_text("{ not valid json")
|
||||
origins = server_module.get_cors_origins()
|
||||
assert "http://localhost:8080" in origins
|
||||
|
||||
|
||||
@pytest.mark.parametrize("which_result,expected", [(None, "offline"), ("/usr/bin/opencode", "online")])
|
||||
def test_check_agent_opencode(server_module, monkeypatch, which_result, expected):
|
||||
monkeypatch.setattr(server_module.shutil, "which", lambda name: which_result)
|
||||
assert server_module.check_agent("opencode") == {"name": "opencode", "status": expected}
|
||||
|
||||
|
||||
def test_check_agent_unknown_is_offline(server_module):
|
||||
assert server_module.check_agent("mystery")["status"] == "offline"
|
||||
|
||||
|
||||
def test_check_agent_gemini_warning_when_not_logged_in(server_module, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(server_module.shutil, "which", lambda name: "/usr/bin/gemini")
|
||||
monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path))
|
||||
# No oauth creds file -> installed but not logged in -> warning.
|
||||
assert server_module.check_agent("gemini")["status"] == "warning"
|
||||
|
||||
|
||||
def test_check_agent_gemini_online_when_logged_in(server_module, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(server_module.shutil, "which", lambda name: "/usr/bin/gemini")
|
||||
monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path))
|
||||
creds = tmp_path / ".gemini" / "oauth_creds.json"
|
||||
creds.parent.mkdir(parents=True)
|
||||
creds.write_text('{"token": "ya29.abc"}')
|
||||
assert server_module.check_agent("gemini")["status"] == "online"
|
||||
|
||||
|
||||
def test_clean_hermes_output_empty(server_module):
|
||||
assert server_module.clean_hermes_output("") == ""
|
||||
|
||||
|
||||
def test_clean_hermes_output_extracts_box_content(server_module):
|
||||
raw = "Query: hi\n╭─ box\nHello there\nSecond line\n╰─ end\nDuration: 1s"
|
||||
assert server_module.clean_hermes_output(raw) == "Hello there\nSecond line"
|
||||
|
||||
|
||||
def test_clean_hermes_output_fallback_without_box(server_module):
|
||||
raw = "Query: hi\nInitializing...\nActual answer here"
|
||||
assert "Actual answer here" in server_module.clean_hermes_output(raw)
|
||||
|
||||
|
||||
def test_kanban_task_path_rejects_bad_id(server_module):
|
||||
with pytest.raises(server_module.HTTPException):
|
||||
server_module.kanban_task_path("../../etc/passwd")
|
||||
|
||||
|
||||
def test_kanban_task_path_rejects_empty(server_module):
|
||||
with pytest.raises(server_module.HTTPException):
|
||||
server_module.kanban_task_path("")
|
||||
|
||||
|
||||
def test_kanban_task_path_accepts_valid_id(server_module):
|
||||
path = server_module.kanban_task_path("abc123")
|
||||
assert path.name == "abc123.json"
|
||||
assert path.parent == server_module.KANBAN_DIR.resolve()
|
||||
|
||||
|
||||
def test_load_save_chat_history_roundtrip(server_module):
|
||||
assert server_module.load_chat_history() == {"messages": []}
|
||||
server_module.save_chat_message({"content": "hi"})
|
||||
assert server_module.load_chat_history()["messages"][-1]["content"] == "hi"
|
||||
|
||||
|
||||
def test_save_chat_message_caps_at_200(server_module):
|
||||
for i in range(210):
|
||||
server_module.save_chat_message({"content": str(i)})
|
||||
history = server_module.load_chat_history()
|
||||
assert len(history["messages"]) == 200
|
||||
assert history["messages"][-1]["content"] == "209"
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
"""Tests for the Kanban board endpoints and autonomous dispatch logic."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sync_threads(server_module, monkeypatch):
|
||||
"""Run ``threading.Thread`` targets synchronously so dispatch is deterministic."""
|
||||
class _SyncThread:
|
||||
def __init__(self, target=None, args=(), kwargs=None, daemon=None):
|
||||
self._target = target
|
||||
self._args = args
|
||||
self._kwargs = kwargs or {}
|
||||
|
||||
def start(self):
|
||||
if self._target:
|
||||
self._target(*self._args, **self._kwargs)
|
||||
|
||||
monkeypatch.setattr(server_module.threading, "Thread", _SyncThread)
|
||||
return server_module
|
||||
|
||||
|
||||
def _create(client, **kw):
|
||||
payload = {"title": "T", "body": "", "status": "triage",
|
||||
"priority": "medium", "assignee": ""}
|
||||
payload.update(kw)
|
||||
return client.post("/api/kanban/tasks", json=payload).json()
|
||||
|
||||
|
||||
def test_board_empty(client):
|
||||
body = client.get("/api/kanban/board").json()
|
||||
assert body["total"] == 0
|
||||
assert set(body["columns"]) == {"triage", "todo", "ready", "in_progress", "blocked", "done"}
|
||||
|
||||
|
||||
def test_create_and_get_task(client):
|
||||
task = _create(client, title="Write tests", assignee="")
|
||||
assert task["title"] == "Write tests"
|
||||
assert task["status"] == "triage"
|
||||
fetched = client.get(f"/api/kanban/tasks/{task['id']}").json()
|
||||
assert fetched["id"] == task["id"]
|
||||
|
||||
|
||||
def test_get_missing_task_404(client):
|
||||
assert client.get("/api/kanban/tasks/abc123").status_code == 404
|
||||
|
||||
|
||||
def test_board_groups_by_status(client):
|
||||
_create(client, status="todo")
|
||||
_create(client, status="done")
|
||||
board = client.get("/api/kanban/board").json()
|
||||
assert len(board["columns"]["todo"]) == 1
|
||||
assert len(board["columns"]["done"]) == 1
|
||||
assert board["total"] == 2
|
||||
# filter by status query
|
||||
filtered = client.get("/api/kanban/board?status=todo").json()
|
||||
assert filtered["total"] == 1
|
||||
|
||||
|
||||
def test_update_task(client):
|
||||
task = _create(client)
|
||||
updated = client.patch(f"/api/kanban/tasks/{task['id']}",
|
||||
json={"title": "renamed", "priority": "high"}).json()
|
||||
assert updated["title"] == "renamed"
|
||||
assert updated["priority"] == "high"
|
||||
|
||||
|
||||
def test_update_missing_404(client):
|
||||
assert client.patch("/api/kanban/tasks/abc123", json={"title": "x"}).status_code == 404
|
||||
|
||||
|
||||
def test_complete_block_unblock(client):
|
||||
task = _create(client)
|
||||
tid = task["id"]
|
||||
assert client.post(f"/api/kanban/tasks/{tid}/complete", json={"summary": "done!"}).json()["status"] == "done"
|
||||
assert client.post(f"/api/kanban/tasks/{tid}/block", json={"reason": "stuck"}).json()["status"] == "blocked"
|
||||
unblocked = client.post(f"/api/kanban/tasks/{tid}/unblock").json()
|
||||
assert unblocked["status"] == "ready"
|
||||
assert unblocked["block_reason"] == ""
|
||||
|
||||
|
||||
def test_comments(client):
|
||||
task = _create(client)
|
||||
updated = client.post(f"/api/kanban/tasks/{task['id']}/comments", json={"message": "hi there"}).json()
|
||||
assert updated["comments"][0]["message"] == "hi there"
|
||||
|
||||
|
||||
def test_links_add_and_remove(client):
|
||||
parent = _create(client)
|
||||
child = _create(client)
|
||||
r = client.post("/api/kanban/links", json={"parent_id": parent["id"], "child_id": child["id"]})
|
||||
assert r.json() == {"status": "linked"}
|
||||
linked = client.get(f"/api/kanban/tasks/{parent['id']}").json()
|
||||
assert {"parent": parent["id"], "child": child["id"]} in linked["links"]
|
||||
r = client.delete(f"/api/kanban/links?parent_id={parent['id']}&child_id={child['id']}")
|
||||
assert r.json() == {"status": "unlinked"}
|
||||
unlinked = client.get(f"/api/kanban/tasks/{parent['id']}").json()
|
||||
assert unlinked["links"] == []
|
||||
|
||||
|
||||
def test_link_missing_task_404(client):
|
||||
parent = _create(client)
|
||||
assert client.post("/api/kanban/links",
|
||||
json={"parent_id": parent["id"], "child_id": "abcdef"}).status_code == 404
|
||||
|
||||
|
||||
def test_specify_moves_triage_to_todo(client):
|
||||
task = _create(client, status="triage")
|
||||
assert client.post(f"/api/kanban/tasks/{task['id']}/specify").json()["status"] == "todo"
|
||||
|
||||
|
||||
def test_decompose_creates_children(client):
|
||||
task = _create(client, body="- first subtask\n- second subtask\n\n")
|
||||
body = client.post(f"/api/kanban/tasks/{task['id']}/decompose").json()
|
||||
assert body["parent"] == task["id"]
|
||||
assert len(body["children"]) == 2
|
||||
titles = [c["title"] for c in body["children"]]
|
||||
assert "first subtask" in titles
|
||||
|
||||
|
||||
def test_dispatch_requires_valid_assignee(client):
|
||||
task = _create(client, assignee="")
|
||||
assert client.post(f"/api/kanban/tasks/{task['id']}/dispatch").status_code == 400
|
||||
|
||||
|
||||
def test_create_with_agent_assignee_dispatches(client, sync_threads, monkeypatch):
|
||||
monkeypatch.setattr(sync_threads, "execute_agent", lambda agent, prompt: "agent finished the job")
|
||||
task = _create(client, title="Do work", assignee="opencode")
|
||||
# With synchronous dispatch, the task runs to completion immediately.
|
||||
assert task["status"] == "done"
|
||||
assert task["summary"].startswith("agent finished")
|
||||
assert any("opencode" in c["message"] for c in task["comments"])
|
||||
|
||||
|
||||
def test_dispatch_failure_blocks_task(client, sync_threads, monkeypatch):
|
||||
monkeypatch.setattr(sync_threads, "execute_agent", lambda agent, prompt: "⚠ Agent not installed")
|
||||
task = _create(client, title="Do work", assignee="hermes")
|
||||
assert task["status"] == "blocked"
|
||||
assert "not installed" in task["block_reason"]
|
||||
|
||||
|
||||
def test_bulk_dispatch_endpoint(client, sync_threads, monkeypatch):
|
||||
monkeypatch.setattr(sync_threads, "execute_agent", lambda agent, prompt: "ok done")
|
||||
|
||||
# Write task files directly so they start eligible (todo/ready with an agent
|
||||
# assignee) — going through the create endpoint would auto-dispatch them.
|
||||
def _seed(tid, status, assignee):
|
||||
sync_threads.save_kanban_task({
|
||||
"id": tid, "title": "t", "body": "", "status": status,
|
||||
"priority": "medium", "assignee": assignee, "comments": [], "links": [],
|
||||
})
|
||||
|
||||
_seed("aaaaaa", "todo", "gemini")
|
||||
_seed("bbbbbb", "ready", "opencode")
|
||||
_seed("cccccc", "triage", "") # not eligible
|
||||
body = client.post("/api/kanban/dispatch").json()
|
||||
assert len(body["dispatched"]) == 2
|
||||
Loading…
Reference in New Issue