diff --git a/tests/conftest.py b/tests/conftest.py index f9ef956..c862559 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,13 @@ -"""Shared test fixtures.""" +"""Shared test fixtures. + +Reusable non-fixture helpers live in ``tests.support`` (mock git/PR helpers, etc.). +""" import os import boto3 import pytest +from fastapi.testclient import TestClient from moto import mock_aws os.environ.setdefault("PIPELINE_LOG", "/tmp/test-pipeline.log") @@ -70,6 +74,21 @@ def tmp_tasks(): yield store +@pytest.fixture +def client(tmp_tasks, monkeypatch): + """FastAPI TestClient with a fresh DynamoTaskStore; auth off; runner/cancel are no-ops.""" + import src.routers.tasks as tasks_router + import src.web as web_mod + + monkeypatch.setattr(tasks_router, "_get_store", lambda: tmp_tasks) + monkeypatch.setattr(web_mod, "trigger_runner", lambda task_id: None) + monkeypatch.setattr(web_mod, "cancel_runner", lambda task_id: None) + monkeypatch.setattr(web_mod, "AUTH_ENABLED", False) + from src.web import app + + return TestClient(app, raise_server_exceptions=True) + + @pytest.fixture def tmp_log(tmp_path, monkeypatch): """Provide a temporary pipeline log path and patch the module.""" @@ -78,6 +97,6 @@ def tmp_log(tmp_path, monkeypatch): monkeypatch.setattr(pl, "LOG_PATH", log_path) monkeypatch.setattr(pl, "_handler_attached", False) - monkeypatch.setattr(pl, "_dynamo_log_store", None) + monkeypatch.setattr(pl, "_dynamo_log_store", False) pl._pipeline_logger.handlers.clear() return log_path diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..4f23441 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,76 @@ +""" +Shared helpers for tests — mock git/subprocess shapes, PR store stubs, command dispatch. + +Import from ``tests.support`` in test modules (not collected as tests). +""" + +from typing import Any, Callable, Optional, Tuple, Union +from unittest.mock import MagicMock + +# --------------------------------------------------------------------------- +# Subprocess / git CLI mocks (``src.pr._run_cmd`` and similar) +# --------------------------------------------------------------------------- + + +def mock_process(returncode=0, stdout="", stderr=""): + # type: (int, str, str) -> MagicMock + """Build a MagicMock matching ``subprocess.CompletedProcess`` fields tests read.""" + m = MagicMock() + m.returncode = returncode + m.stdout = stdout + m.stderr = stderr + return m + + +def git_cmd_join(cmd): + # type: (Any) -> str + """Join a argv list the same way PR tests match substrings (``"git status" in joined``).""" + if isinstance(cmd, list): + return " ".join(str(c) for c in cmd) + return str(cmd) + + +GitCmdRule = Union[ + Tuple[str, Any], + Tuple[Callable[[Any, Optional[str], str], bool], Any], +] + + +def git_cmd_side_effect(rules, default=None): + # type: (list, Any) -> Callable[..., Any] + """Return ``side_effect`` for a mock ``_run_cmd``. + + Each *rule* is either: + + - ``(substring, result)`` — first match where *substring* appears in the joined argv wins. + - ``(predicate, result)`` — *predicate* is ``(cmd, cwd, joined) -> bool``. + + Rules are evaluated in order; use specific predicates before broad substrings. + """ + if default is None: + default = mock_process() + + def side_effect(cmd, cwd=None, timeout=None): + joined = git_cmd_join(cmd) + for first, result in rules: + if isinstance(first, str): + if first in joined: + return result + elif first(cmd, cwd, joined): + return result + return default + + return side_effect + + +# --------------------------------------------------------------------------- +# DynamoTaskStore stubs for PR pipeline tests +# --------------------------------------------------------------------------- + + +def attach_pr_mocks(store): + # type: (Any) -> Any + """Stub ``set_pr_url`` / ``append_section`` so PR code does not hit Dynamo for those paths.""" + store.set_pr_url = MagicMock() + store.append_section = MagicMock() + return store diff --git a/tests/test_agent_context.py b/tests/test_agent_context.py new file mode 100644 index 0000000..2acd63c --- /dev/null +++ b/tests/test_agent_context.py @@ -0,0 +1,39 @@ +"""Tests for agent prompt helpers (project context injection).""" + +from types import SimpleNamespace + +from src.agent import _project_context_markdown, build_prompt + + +def test_project_context_markdown_empty_id(): + assert _project_context_markdown("") == "" + + +def test_project_context_markdown_missing_project(monkeypatch): + monkeypatch.setattr("src.projects_dynamo.get_project", lambda _pid: None) + assert _project_context_markdown("any") == "" + + +def test_project_context_markdown_with_spec(monkeypatch): + monkeypatch.setattr( + "src.projects_dynamo.get_project", + lambda pid: ( + {"title": " App ", "spec": " Do things "} if pid == "p1" else None + ), + ) + md = _project_context_markdown("p1") + assert "**App**" in md + assert "Do things" in md + + +def test_build_prompt_includes_working_dir(): + task = SimpleNamespace( + title="T", + description=None, + tags=None, + role="", + project_id="", + ) + out = build_prompt(task, agent_cwd="/tmp/wt") + assert "/tmp/wt" in out + assert "YOUR WORKING DIRECTORY" in out diff --git a/tests/test_api.py b/tests/test_api.py index 035e76a..388d613 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,28 +6,9 @@ Auth is disabled (AUTH_EMAIL/AUTH_PASSWORD unset in conftest). """ -import pytest -from fastapi.testclient import TestClient -import src.routers.tasks as tasks_router -import src.web as web_mod from src.task_store import TaskStatus - -@pytest.fixture -def client(tmp_tasks, monkeypatch): - """TestClient wired to a fresh DynamoTaskStore with no auth and no real runner.""" - monkeypatch.setattr(tasks_router, "_get_store", lambda: tmp_tasks) - monkeypatch.setattr(web_mod, "trigger_runner", lambda task_id: None) - monkeypatch.setattr(web_mod, "cancel_runner", lambda task_id: None) - # Tests assume auth is off; host env may set AUTH_EMAIL/PASSWORD (setdefault in conftest - # does not override). Force-disable so /api/* returns 200, not 401. - monkeypatch.setattr(web_mod, "AUTH_ENABLED", False) - from src.web import app - - return TestClient(app, raise_server_exceptions=True) - - # --------------------------------------------------------------------------- # GET /api/tasks # --------------------------------------------------------------------------- diff --git a/tests/test_dynamo_store.py b/tests/test_dynamo_store.py index dd97f6e..bebdad8 100644 --- a/tests/test_dynamo_store.py +++ b/tests/test_dynamo_store.py @@ -272,11 +272,6 @@ def get_item(Key, **kwargs): assert found[0].id == "child" -def test_list_tasks_for_project_empty(): - store = _make_store(MagicMock()) - assert store.list_tasks_for_project("") == [] - - def test_maybe_finalize_directive_batch(monkeypatch): table = MagicMock() store = _make_store(table) @@ -542,3 +537,11 @@ def test_add_comment_success(): assert c is not None assert c.author == "me" table.put_item.assert_called_once() + + +def test_list_tasks_for_project_empty_id_returns_empty(tmp_tasks): + assert tmp_tasks.list_tasks_for_project("") == [] + + +def test_list_human_reply_pending_empty_project_returns_empty(tmp_tasks): + assert tmp_tasks.list_human_reply_pending_for_project("") == [] diff --git a/tests/test_healer_stale.py b/tests/test_healer_stale.py new file mode 100644 index 0000000..ec3b93d --- /dev/null +++ b/tests/test_healer_stale.py @@ -0,0 +1,69 @@ +"""Tests for heal_stale_worktrees and run_healer orchestration.""" + +import os +import time +from unittest.mock import MagicMock + +from src.task_store import TaskStatus + + +class TestHealStaleWorktrees: + def test_removes_old_worktree_when_task_not_in_progress( + self, tmp_tasks, tmp_path, monkeypatch + ): + from src.healer import heal_stale_worktrees + + base = tmp_path / "worktrees" + base.mkdir() + monkeypatch.setattr("src.worktree.WORKTREE_BASE", base) + monkeypatch.setenv("STALE_WORKTREE_DAYS", "0") + + task = tmp_tasks.create(title="Stale") + tid = task.id + wt = base / ("task-%s" % tid) + wt.mkdir() + old = time.time() - 10 * 86400 + os.utime(str(wt), (old, old)) + + plog_calls = [] + + def plog(*a, **k): + plog_calls.append((a, k)) + + n = heal_stale_worktrees(tmp_tasks, plog) + assert n == 1 + assert not wt.exists() + assert any("heal_stale_worktree" in str(c) for c in plog_calls) + + def test_skips_in_progress_task(self, tmp_tasks, tmp_path, monkeypatch): + from src.healer import heal_stale_worktrees + + base = tmp_path / "worktrees2" + base.mkdir() + monkeypatch.setattr("src.worktree.WORKTREE_BASE", base) + monkeypatch.setenv("STALE_WORKTREE_DAYS", "0") + + task = tmp_tasks.create(title="Running") + tmp_tasks.update_status(task.id, TaskStatus.IN_PROGRESS) + tid = task.id + wt = base / ("task-%s" % tid) + wt.mkdir() + old = time.time() - 10 * 86400 + os.utime(str(wt), (old, old)) + + n = heal_stale_worktrees(tmp_tasks, lambda *a, **k: None) + assert n == 0 + assert wt.exists() + + +class TestRunHealer: + def test_returns_tuple_from_subhealers(self, monkeypatch): + from src.healer import run_healer + + monkeypatch.setattr("src.healer.heal_stale_in_progress", lambda s, p: 2) + monkeypatch.setattr("src.healer.heal_branch_no_pr", lambda s, p: 3) + monkeypatch.setattr("src.healer.heal_cancelled_with_work", lambda s, p: 5) + monkeypatch.setattr("src.healer.heal_stale_worktrees", lambda s, p: 7) + + out = run_healer(MagicMock()) + assert out == (2, 3, 5, 7) diff --git a/tests/test_health_router.py b/tests/test_health_router.py index f9a452a..e07b1dc 100644 --- a/tests/test_health_router.py +++ b/tests/test_health_router.py @@ -32,6 +32,27 @@ def test_last_log_timestamp_fallback_line(tmp_path): assert out +def test_last_log_timestamp_uses_last_non_json_line(tmp_path): + p = tmp_path / "mixed.log" + p.write_text("older-line\nlast-line-plain\n") + assert _last_log_timestamp(p) == "last-line-plain" + + +def test_last_log_timestamp_skips_frontmatter_dashes(tmp_path): + p = tmp_path / "fm.log" + p.write_text("---\nuse-this-line\n") + out = _last_log_timestamp(p) + assert "use-this" in out + + +def test_last_log_timestamp_open_oserror(tmp_path, monkeypatch): + def boom(*_a, **_kw): + raise OSError("denied") + + monkeypatch.setattr("builtins.open", boom) + assert _last_log_timestamp(tmp_path / "any.log") == "" + + def test_api_health(monkeypatch): monkeypatch.setattr("src.web.store", MagicMock(list_tasks=lambda: [])) client = TestClient(app) @@ -50,3 +71,14 @@ def test_api_health(monkeypatch): "failed", "cancelled", } + + +def test_api_health_survives_store_error(monkeypatch): + def boom(): + raise RuntimeError("ddb") + + monkeypatch.setattr("src.web.store", MagicMock(list_tasks=boom)) + client = TestClient(app) + r = client.get("/api/health") + assert r.status_code == 200 + assert r.json()["task_counts"] == {} diff --git a/tests/test_pm_agent_helpers.py b/tests/test_pm_agent_helpers.py new file mode 100644 index 0000000..1cbd341 --- /dev/null +++ b/tests/test_pm_agent_helpers.py @@ -0,0 +1,92 @@ +"""Unit tests for PM agent JSON parsing and task normalization helpers.""" + + +class TestParsePmJson: + def test_plain_json(self): + from src.pm_agent import _parse_pm_json + + assert _parse_pm_json('{"actions": []}') == {"actions": []} + + def test_json_in_fence(self): + from src.pm_agent import _parse_pm_json + + text = 'Here:\n```json\n{"reply": "ok"}\n```' + assert _parse_pm_json(text) == {"reply": "ok"} + + def test_embedded_json(self): + from src.pm_agent import _parse_pm_json + + text = 'Prefix {"x": 1} suffix' + assert _parse_pm_json(text) == {"x": 1} + + def test_invalid_returns_none(self): + from src.pm_agent import _parse_pm_json + + assert _parse_pm_json("not json") is None + + +class TestNormalizeAgentTask: + def test_valid(self): + from src.pm_agent import _normalize_agent_task + + out = _normalize_agent_task( + { + "title": "Do thing", + "description": "Details", + "priority": "high", + "role": "be_engineer", + } + ) + assert out == { + "title": "Do thing", + "description": "Details", + "role": "be_engineer", + "priority": "high", + } + + def test_invalid_priority_defaults_medium(self): + from src.pm_agent import _normalize_agent_task + + out = _normalize_agent_task({"title": "T", "priority": "nope"}) + assert out["priority"] == "medium" + + def test_invalid_role_defaults_fullstack(self): + from src.pm_agent import _normalize_agent_task + + out = _normalize_agent_task({"title": "T", "role": "not_a_real_role"}) + assert out["role"] == "fullstack_engineer" + + def test_non_dict_returns_none(self): + from src.pm_agent import _normalize_agent_task + + assert _normalize_agent_task("x") is None + assert _normalize_agent_task(None) is None + + def test_empty_title_returns_none(self): + from src.pm_agent import _normalize_agent_task + + assert _normalize_agent_task({"title": " "}) is None + + +class TestNormalizeHumanTask: + def test_valid(self): + from src.pm_agent import _normalize_human_task + + out = _normalize_human_task({"title": "Need API key", "priority": "urgent"}) + assert out["title"] == "Need API key" + assert out["priority"] == "urgent" + assert "role" not in out + + def test_non_dict_returns_none(self): + from src.pm_agent import _normalize_human_task + + assert _normalize_human_task({}) is None + + +def test_build_role_list_includes_ids(): + from src.pm_agent import _build_role_list + from src.roles import ROLES + + text = _build_role_list() + assert "fe_engineer" in text + assert len(text.splitlines()) == len(ROLES) diff --git a/tests/test_pr_helpers_extra.py b/tests/test_pr_helpers_extra.py new file mode 100644 index 0000000..1ba7e44 --- /dev/null +++ b/tests/test_pr_helpers_extra.py @@ -0,0 +1,119 @@ +"""Additional PR module tests: sensitive files, wrong-dir sentinel, CI polling.""" + +import json +from unittest.mock import patch + +from tests.support import attach_pr_mocks, git_cmd_side_effect, mock_process + + +class TestCheckSensitiveFiles: + def test_override_tag_skips_check(self, tmp_tasks): + from src.pr import _check_sensitive_files + + task = tmp_tasks.create(title="t", tags=["allow-sensitive-files"]) + diff = mock_process(stdout=".env\n") + with patch("src.pr._run_cmd", return_value=diff): + assert _check_sensitive_files(task, "/tmp/wt") == [] + + def test_blocks_env_file(self, tmp_tasks): + from src.pr import _check_sensitive_files + + task = tmp_tasks.create(title="t") + diff = mock_process(stdout=".env\n") + with patch("src.pr._run_cmd", return_value=diff): + assert _check_sensitive_files(task, "/tmp/wt") == [".env"] + + +class TestCommitWrongDir: + @patch("src.pr._verify_pr_diff", return_value=(True, "LGTM")) + @patch("src.pr._run_cmd") + def test_wrong_dir_sentinel_when_main_has_changes( + self, mock_cmd, mock_verify, tmp_tasks, monkeypatch + ): + from src.pr import WRONG_DIR_SENTINEL, commit_and_create_pr + + store = attach_pr_mocks(tmp_tasks) + task = tmp_tasks.create(title="Wrong dir", target_repo="task-forge") + + rules = [ + ( + lambda c, wd, j: "status --porcelain" in j and wd == "/tmp/wt", + mock_process(), + ), + ( + lambda c, wd, j: "status --porcelain" in j and wd != "/tmp/wt", + mock_process(stdout="M other.py"), + ), + ] + mock_cmd.side_effect = git_cmd_side_effect(rules) + monkeypatch.setattr("src.pr._resolve_repo_dir", lambda t: "/main/checkout") + + result = commit_and_create_pr(store, task, "/tmp/wt") + assert result == WRONG_DIR_SENTINEL + store.set_pr_url.assert_not_called() + + +class TestPollCiStatus: + @patch("time.monotonic", return_value=0.0) + @patch("time.sleep") + @patch("src.pr._run_cmd") + def test_gh_error_returns_none_summary(self, mock_cmd, mock_sleep, mock_mono): + from src.pr import poll_ci_status + + mock_cmd.return_value = mock_process(returncode=1, stderr="err") + ok, summary = poll_ci_status("42", "/repo", timeout=5) + assert ok is True + assert summary is None + mock_sleep.assert_called() + + @patch("time.monotonic", return_value=0.0) + @patch("time.sleep") + @patch("src.pr._run_cmd") + def test_failed_check_returns_false(self, mock_cmd, mock_sleep, mock_mono): + from src.pr import poll_ci_status + + failed = json.dumps( + [{"state": "FAILURE", "name": "build", "conclusion": None}] + ) + mock_cmd.return_value = mock_process(stdout=failed) + ok, summary = poll_ci_status("7", "/repo", timeout=30) + assert ok is False + assert "failed" in (summary or "").lower() + assert "build" in (summary or "") + + @patch("time.monotonic", return_value=0.0) + @patch("time.sleep") + @patch("src.pr._run_cmd") + def test_empty_checks_returns_none_summary(self, mock_cmd, mock_sleep, mock_mono): + from src.pr import poll_ci_status + + mock_cmd.return_value = mock_process(stdout="[]") + ok, summary = poll_ci_status("1", "/repo", timeout=30) + assert ok is True + assert summary is None + + @patch("time.monotonic", return_value=0.0) + @patch("time.sleep") + @patch("src.pr._run_cmd") + def test_all_checks_passed_message(self, mock_cmd, mock_sleep, mock_mono): + from src.pr import poll_ci_status + + payload = json.dumps( + [{"state": "SUCCESS", "name": "ci", "conclusion": None}] + ) + mock_cmd.return_value = mock_process(stdout=payload) + ok, summary = poll_ci_status("3", "/repo", timeout=30) + assert ok is True + assert summary and "passed" in summary.lower() + + @patch("time.monotonic", side_effect=[0.0, 0.0, 100.0]) + @patch("time.sleep") + @patch("src.pr._run_cmd") + def test_timeout_returns_still_running_message(self, mock_cmd, mock_sleep, mock_mono): + from src.pr import poll_ci_status + + pending = json.dumps([{"state": "PENDING", "name": "slow"}]) + mock_cmd.return_value = mock_process(stdout=pending) + ok, summary = poll_ci_status("9", "/repo", timeout=5) + assert ok is True + assert summary and "timed out" in summary.lower() diff --git a/tests/test_pr_pipeline.py b/tests/test_pr_pipeline.py index cc49077..9723956 100644 --- a/tests/test_pr_pipeline.py +++ b/tests/test_pr_pipeline.py @@ -1,9 +1,10 @@ """Tests for PR URL storage and model stamping in the pipeline.""" import subprocess -from unittest.mock import MagicMock, patch +from unittest.mock import patch from src.task_store import TaskStatus +from tests.support import attach_pr_mocks, git_cmd_side_effect, mock_process class TestSetPrUrl: @@ -31,29 +32,9 @@ def test_survives_status_update(self, tmp_tasks): assert tmp_tasks.get_pr_url(task.id) == "https://github.com/user/repo/pull/42" -def _make_run_cmd(return_map=None): - """Build a mock _run_cmd that returns different results based on the first arg.""" - defaults = MagicMock(returncode=0, stdout="", stderr="") - - def side_effect(cmd, cwd=None, timeout=None): - if return_map: - for key, val in return_map.items(): - if key in cmd or (isinstance(cmd, list) and any(key in str(c) for c in cmd)): - return val - return defaults - - return side_effect - - class TestCommitAndCreatePr: """commit_and_create_pr stores pr_url on success, skips on no-changes.""" - def _make_store(self, tmp_tasks): - store = tmp_tasks - store.set_pr_url = MagicMock() - store.append_section = MagicMock() - return store - @patch("src.pr._verify_pr_diff", return_value=(True, "LGTM")) @patch("src.pr._wait_for_pr_ci", return_value=True) @patch("src.pr._generate_pr_body", return_value="body") @@ -64,46 +45,24 @@ def test_sets_pr_url_on_success( ): from src.pr import commit_and_create_pr - store = self._make_store(tmp_tasks) + store = attach_pr_mocks(tmp_tasks) task = tmp_tasks.create(title="Test task", target_repo="task-forge") - status_result = MagicMock(returncode=0, stdout="M file.py", stderr="") - add_result = MagicMock(returncode=0, stdout="", stderr="") - commit_result = MagicMock(returncode=0, stdout="", stderr="") - branch_result = MagicMock(returncode=0, stdout="task/abc-test", stderr="") - push_result = MagicMock(returncode=0, stdout="", stderr="") - pr_result = MagicMock( - returncode=0, + pr_result = mock_process( stdout="https://github.com/user/repo/pull/99\n", - stderr="", ) - default_branch = MagicMock(returncode=0, stdout="main", stderr="") - blocked_result = MagicMock(returncode=0, stdout="", stderr="") - - def side_effect(cmd, cwd=None, timeout=None): - if isinstance(cmd, list): - joined = " ".join(str(c) for c in cmd) - if "status --porcelain" in joined: - return status_result - if "add -A" in joined: - return add_result - if "diff --cached --name-only" in joined: - return blocked_result - if "commit -m" in joined: - return commit_result - if "rev-parse --abbrev-ref HEAD" in joined: - return branch_result - if "push -u" in joined: - return push_result - if "pr create" in joined: - return pr_result - if "rev-parse --show-toplevel" in joined: - return MagicMock(returncode=0, stdout="/tmp/repo", stderr="") - if "symbolic-ref" in joined: - return default_branch - return MagicMock(returncode=0, stdout="", stderr="") - - mock_cmd.side_effect = side_effect + rules = [ + ("status --porcelain", mock_process(stdout="M file.py")), + ("add -A", mock_process()), + ("diff --cached --name-only", mock_process()), + ("commit -m", mock_process()), + ("rev-parse --abbrev-ref HEAD", mock_process(stdout="task/abc-test")), + ("push -u", mock_process()), + ("pr create", pr_result), + ("rev-parse --show-toplevel", mock_process(stdout="/tmp/repo")), + ("symbolic-ref", mock_process(stdout="main")), + ] + mock_cmd.side_effect = git_cmd_side_effect(rules) result = commit_and_create_pr(store, task, "/tmp/wt") assert result == "https://github.com/user/repo/pull/99" @@ -113,11 +72,11 @@ def side_effect(cmd, cwd=None, timeout=None): def test_no_changes_skips_pr_url(self, mock_cmd, tmp_tasks): from src.pr import NO_CHANGES_SENTINEL, commit_and_create_pr - store = self._make_store(tmp_tasks) + store = attach_pr_mocks(tmp_tasks) task = tmp_tasks.create(title="Clean task", target_repo="task-forge") # Worktree is clean - mock_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + mock_cmd.return_value = mock_process() result = commit_and_create_pr(store, task, "/tmp/wt") assert result == NO_CHANGES_SENTINEL @@ -128,29 +87,18 @@ def test_no_changes_skips_pr_url(self, mock_cmd, tmp_tasks): def test_push_failed_skips_pr_url(self, mock_cmd, mock_sensitive, tmp_tasks): from src.pr import PUSH_FAILED_SENTINEL, commit_and_create_pr - store = self._make_store(tmp_tasks) + store = attach_pr_mocks(tmp_tasks) task = tmp_tasks.create(title="Push fail", target_repo="task-forge") - call_count = {"n": 0} - - def side_effect(cmd, cwd=None, timeout=None): - call_count["n"] += 1 - joined = " ".join(str(c) for c in cmd) if isinstance(cmd, list) else str(cmd) - if "status --porcelain" in joined: - return MagicMock(returncode=0, stdout="M file.py", stderr="") - if "add -A" in joined: - return MagicMock(returncode=0, stdout="", stderr="") - if "diff --cached --name-only" in joined: - return MagicMock(returncode=0, stdout="", stderr="") - if "commit -m" in joined: - return MagicMock(returncode=0, stdout="", stderr="") - if "rev-parse --abbrev-ref HEAD" in joined: - return MagicMock(returncode=0, stdout="task/abc-test", stderr="") - if "push -u" in joined: - return MagicMock(returncode=1, stdout="", stderr="push failed") - return MagicMock(returncode=0, stdout="", stderr="") - - mock_cmd.side_effect = side_effect + rules = [ + ("status --porcelain", mock_process(stdout="M file.py")), + ("add -A", mock_process()), + ("diff --cached --name-only", mock_process()), + ("commit -m", mock_process()), + ("rev-parse --abbrev-ref HEAD", mock_process(stdout="task/abc-test")), + ("push -u", mock_process(returncode=1, stderr="push failed")), + ] + mock_cmd.side_effect = git_cmd_side_effect(rules) result = commit_and_create_pr(store, task, "/tmp/wt") assert result == PUSH_FAILED_SENTINEL diff --git a/tests/test_pr_verify_body.py b/tests/test_pr_verify_body.py new file mode 100644 index 0000000..9349989 --- /dev/null +++ b/tests/test_pr_verify_body.py @@ -0,0 +1,79 @@ +"""Tests for PR verification and PR body generation (mocked git + agent).""" + +from types import SimpleNamespace +from unittest.mock import patch + +from tests.support import mock_process + + +@patch("src.pr.run_agent") +@patch("src.pr._run_cmd") +def test_verify_pr_diff_accepts_lgtm(mock_cmd, mock_agent, tmp_tasks): + from src.pr import _verify_pr_diff + + task = tmp_tasks.create(title="Task title", description="Desc") + mock_cmd.return_value = mock_process(stdout=" file | 1 +\n") + cp = SimpleNamespace(returncode=0, stdout="LGTM looks good\n") + mock_agent.return_value = (cp, 1.0, "", {}) + + ok, note = _verify_pr_diff(task, "/tmp/wt") + assert ok is True + assert "LGTM" in note or note == "LGTM looks good" + + +@patch("src.pr.run_agent") +@patch("src.pr._run_cmd") +def test_verify_pr_diff_flags_concern(mock_cmd, mock_agent, tmp_tasks): + from src.pr import _verify_pr_diff + + task = tmp_tasks.create(title="T") + mock_cmd.return_value = mock_process(stdout="diff") + cp = SimpleNamespace(returncode=0, stdout="CONCERN: missing tests\n") + mock_agent.return_value = (cp, 1.0, "", {}) + + ok, note = _verify_pr_diff(task, "/tmp/wt") + assert ok is False + assert "CONCERN" in note.upper() + + +@patch("src.pr.run_agent") +@patch("src.pr._run_cmd") +def test_verify_pr_diff_skips_on_agent_error(mock_cmd, mock_agent, tmp_tasks): + from src.pr import _verify_pr_diff + + task = tmp_tasks.create(title="T") + mock_cmd.return_value = mock_process(stdout="x") + mock_agent.side_effect = RuntimeError("agent down") + + ok, note = _verify_pr_diff(task, "/tmp/wt") + assert ok is True + assert "skipped" in note.lower() + + +@patch("src.pr.run_agent") +@patch("src.pr._run_cmd") +def test_generate_pr_body_uses_agent_when_ok(mock_cmd, mock_agent, tmp_tasks): + from src.pr import _generate_pr_body + + task = tmp_tasks.create(title="Feature", description="D") + mock_cmd.return_value = mock_process(stdout="stat") + cp = SimpleNamespace(returncode=0, stdout='{"result":"## Summary\\nDone."}\n') + mock_agent.return_value = (cp, 1.0, "", {}) + + body = _generate_pr_body(task, "/tmp/wt") + assert task.id in body + assert "Summary" in body or "Done" in body + + +@patch("src.pr.run_agent") +@patch("src.pr._run_cmd") +def test_generate_pr_body_fallback_on_exception(mock_cmd, mock_agent, tmp_tasks): + from src.pr import _generate_pr_body + + task = tmp_tasks.create(title="Fallback task") + mock_cmd.return_value = mock_process(stdout=" M x") + mock_agent.side_effect = RuntimeError("no") + + body = _generate_pr_body(task, "/tmp/wt") + assert task.id in body + assert "Automated PR" in body or "Fallback" in body diff --git a/tests/test_watcher.py b/tests/test_watcher.py index a002d95..f0791a3 100644 --- a/tests/test_watcher.py +++ b/tests/test_watcher.py @@ -1,5 +1,7 @@ """Tests for src/watcher.py — snapshot, callback dispatch.""" +import asyncio + import pytest from src.task_store import TaskStatus @@ -89,3 +91,30 @@ async def bad_callback(task, old_status): watcher.on_status_change(bad_callback) await watcher._notify(task, TaskStatus.PENDING) + + +class TestWatchLoop: + @pytest.mark.asyncio + async def test_start_continues_after_snapshot_error(self, tmp_tasks, monkeypatch): + """Poll loop logs and continues if _snapshot raises once.""" + watcher = TaskWatcher(tmp_tasks, poll_interval=0.01) + n = {"calls": 0} + done = asyncio.Event() + real = watcher._snapshot + + def flaky(): + n["calls"] += 1 + if n["calls"] == 2: + raise RuntimeError("ddb") + if n["calls"] >= 3: + watcher.stop() + done.set() + return real() + + monkeypatch.setattr(watcher, "_snapshot", flaky) + tmp_tasks.create(title="loop") + + run = asyncio.create_task(watcher.start()) + await asyncio.wait_for(done.wait(), timeout=3.0) + await asyncio.wait_for(run, timeout=3.0) + assert n["calls"] >= 3 diff --git a/tests/test_worktree_unit.py b/tests/test_worktree_unit.py new file mode 100644 index 0000000..fed6686 --- /dev/null +++ b/tests/test_worktree_unit.py @@ -0,0 +1,26 @@ +"""Unit tests for worktree path helpers (no git network).""" + +from src.worktree import _resolve_repo_dir, _slugify_branch + + +def test_slugify_branch_strips_and_limits(): + assert _slugify_branch(" Hello World! ") == "hello-world" + assert _slugify_branch("a" * 100) == "a" * 40 + + +def test_resolve_repo_dir_workspace_sibling(tmp_tasks, tmp_path, monkeypatch): + monkeypatch.setattr("src.worktree.WORKSPACE_DIR", tmp_path) + name = "sidecar-repo" + repo = tmp_path / name + repo.mkdir() + (repo / ".git").mkdir() + task = tmp_tasks.create(title="t", target_repo=name) + assert _resolve_repo_dir(task) == str(repo) + + +def test_resolve_repo_dir_falls_back_to_project_root(tmp_tasks, tmp_path, monkeypatch): + from src import worktree as wt + + monkeypatch.setattr("src.worktree.WORKSPACE_DIR", tmp_path) + task = tmp_tasks.create(title="t", target_repo="nonexistent-repo-xyz") + assert _resolve_repo_dir(task) == str(wt.PROJECT_ROOT)