"""Unit tests for the self-modification loop. Covers: - Protected branch guard - Successful cycle (mocked git + tests) - Edit function failure → branch reverted, no commit - Test failure → branch reverted, no commit - Gitea PR creation plumbing - GiteaClient graceful degradation (no token, network error) All git and subprocess calls are mocked so these run offline without a real repo or test suite. """ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_loop(repo_root="/tmp/fake-repo"): """Construct a SelfModifyLoop with a fake repo root.""" from self_coding.self_modify.loop import SelfModifyLoop return SelfModifyLoop(repo_root=repo_root, remote="origin", base_branch="main") def _noop_edit(repo_root: str) -> None: """Edit function that does nothing.""" def _failing_edit(repo_root: str) -> None: """Edit function that raises.""" raise RuntimeError("edit exploded") # --------------------------------------------------------------------------- # Guard tests (sync — no git calls needed) # --------------------------------------------------------------------------- @pytest.mark.unit def test_guard_blocks_main(): loop = _make_loop() with pytest.raises(ValueError, match="protected branch"): loop._guard_branch("main") @pytest.mark.unit def test_guard_blocks_master(): loop = _make_loop() with pytest.raises(ValueError, match="protected branch"): loop._guard_branch("master") @pytest.mark.unit def test_guard_allows_feature_branch(): loop = _make_loop() # Should not raise loop._guard_branch("self-modify/some-feature") @pytest.mark.unit def test_guard_allows_self_modify_prefix(): loop = _make_loop() loop._guard_branch("self-modify/issue-983") # --------------------------------------------------------------------------- # Full cycle — success path # --------------------------------------------------------------------------- @pytest.mark.unit @pytest.mark.asyncio async def test_run_success(): """Happy path: edit succeeds, tests pass, PR created.""" loop = _make_loop() fake_completed = MagicMock() fake_completed.stdout = "abc1234\n" fake_completed.returncode = 0 fake_test_result = MagicMock() fake_test_result.stdout = "3 passed" fake_test_result.stderr = "" fake_test_result.returncode = 0 from self_coding.gitea_client import PullRequest as _PR fake_pr = _PR(number=42, title="test PR", html_url="http://gitea/pr/42") with ( patch.object(loop, "_git", return_value=fake_completed), patch("subprocess.run", return_value=fake_test_result), patch.object(loop, "_create_pr", return_value=fake_pr), ): result = await loop.run( slug="test-feature", description="Add test feature", edit_fn=_noop_edit, issue_number=983, ) assert result.success is True assert result.branch == "self-modify/test-feature" assert result.pr_url == "http://gitea/pr/42" assert result.pr_number == 42 assert "3 passed" in result.test_output @pytest.mark.unit @pytest.mark.asyncio async def test_run_skips_tests_when_flag_set(): """skip_tests=True should bypass the test gate.""" loop = _make_loop() fake_completed = MagicMock() fake_completed.stdout = "deadbeef\n" fake_completed.returncode = 0 with ( patch.object(loop, "_git", return_value=fake_completed), patch.object(loop, "_create_pr", return_value=None), patch("subprocess.run") as mock_run, ): result = await loop.run( slug="skip-test-feature", description="Skip test feature", edit_fn=_noop_edit, skip_tests=True, ) # subprocess.run should NOT be called for tests mock_run.assert_not_called() assert result.success is True assert "(tests skipped)" in result.test_output # --------------------------------------------------------------------------- # Failure paths # --------------------------------------------------------------------------- @pytest.mark.unit @pytest.mark.asyncio async def test_run_reverts_on_edit_failure(): """If edit_fn raises, the branch should be reverted and no commit made.""" loop = _make_loop() fake_completed = MagicMock() fake_completed.stdout = "" fake_completed.returncode = 0 revert_called = [] def _fake_revert(branch): revert_called.append(branch) with ( patch.object(loop, "_git", return_value=fake_completed), patch.object(loop, "_revert_branch", side_effect=_fake_revert), patch.object(loop, "_commit_all") as mock_commit, ): result = await loop.run( slug="broken-edit", description="This will fail", edit_fn=_failing_edit, skip_tests=True, ) assert result.success is False assert "edit exploded" in result.error assert "self-modify/broken-edit" in revert_called mock_commit.assert_not_called() @pytest.mark.unit @pytest.mark.asyncio async def test_run_reverts_on_test_failure(): """If tests fail, branch should be reverted and no commit made.""" loop = _make_loop() fake_completed = MagicMock() fake_completed.stdout = "" fake_completed.returncode = 0 fake_test_result = MagicMock() fake_test_result.stdout = "FAILED test_foo" fake_test_result.stderr = "1 failed" fake_test_result.returncode = 1 revert_called = [] def _fake_revert(branch): revert_called.append(branch) with ( patch.object(loop, "_git", return_value=fake_completed), patch("subprocess.run", return_value=fake_test_result), patch.object(loop, "_revert_branch", side_effect=_fake_revert), patch.object(loop, "_commit_all") as mock_commit, ): result = await loop.run( slug="tests-will-fail", description="This will fail tests", edit_fn=_noop_edit, ) assert result.success is False assert "Tests failed" in result.error assert "self-modify/tests-will-fail" in revert_called mock_commit.assert_not_called() @pytest.mark.unit @pytest.mark.asyncio async def test_run_slug_with_main_creates_safe_branch(): """A slug of 'main' produces branch 'self-modify/main', which is not protected.""" loop = _make_loop() fake_completed = MagicMock() fake_completed.stdout = "deadbeef\n" fake_completed.returncode = 0 # 'self-modify/main' is NOT in _PROTECTED_BRANCHES so the run should succeed with ( patch.object(loop, "_git", return_value=fake_completed), patch.object(loop, "_create_pr", return_value=None), ): result = await loop.run( slug="main", description="try to write to self-modify/main", edit_fn=_noop_edit, skip_tests=True, ) assert result.branch == "self-modify/main" assert result.success is True # --------------------------------------------------------------------------- # GiteaClient tests # --------------------------------------------------------------------------- @pytest.mark.unit def test_gitea_client_returns_none_without_token(): """GiteaClient should return None gracefully when no token is set.""" from self_coding.gitea_client import GiteaClient client = GiteaClient(base_url="http://localhost:3000", token="", repo="owner/repo") pr = client.create_pull_request( title="Test PR", body="body", head="self-modify/test", ) assert pr is None @pytest.mark.unit def test_gitea_client_comment_returns_false_without_token(): """add_issue_comment should return False gracefully when no token is set.""" from self_coding.gitea_client import GiteaClient client = GiteaClient(base_url="http://localhost:3000", token="", repo="owner/repo") result = client.add_issue_comment(123, "hello") assert result is False @pytest.mark.unit def test_gitea_client_create_pr_handles_network_error(): """create_pull_request should return None on network failure.""" from self_coding.gitea_client import GiteaClient client = GiteaClient(base_url="http://localhost:3000", token="fake-token", repo="owner/repo") mock_requests = MagicMock() mock_requests.post.side_effect = Exception("Connection refused") mock_requests.exceptions.ConnectionError = Exception with patch.dict("sys.modules", {"requests": mock_requests}): pr = client.create_pull_request( title="Test PR", body="body", head="self-modify/test", ) assert pr is None @pytest.mark.unit def test_gitea_client_comment_handles_network_error(): """add_issue_comment should return False on network failure.""" from self_coding.gitea_client import GiteaClient client = GiteaClient(base_url="http://localhost:3000", token="fake-token", repo="owner/repo") mock_requests = MagicMock() mock_requests.post.side_effect = Exception("Connection refused") with patch.dict("sys.modules", {"requests": mock_requests}): result = client.add_issue_comment(456, "hello") assert result is False @pytest.mark.unit def test_gitea_client_create_pr_success(): """create_pull_request should return a PullRequest on HTTP 201.""" from self_coding.gitea_client import GiteaClient, PullRequest client = GiteaClient(base_url="http://localhost:3000", token="tok", repo="owner/repo") fake_resp = MagicMock() fake_resp.raise_for_status = MagicMock() fake_resp.json.return_value = { "number": 77, "title": "Test PR", "html_url": "http://localhost:3000/owner/repo/pulls/77", } mock_requests = MagicMock() mock_requests.post.return_value = fake_resp with patch.dict("sys.modules", {"requests": mock_requests}): pr = client.create_pull_request("Test PR", "body", "self-modify/feat") assert isinstance(pr, PullRequest) assert pr.number == 77 assert pr.html_url == "http://localhost:3000/owner/repo/pulls/77" # --------------------------------------------------------------------------- # LoopResult dataclass # --------------------------------------------------------------------------- @pytest.mark.unit def test_loop_result_defaults(): from self_coding.self_modify.loop import LoopResult r = LoopResult(success=True) assert r.branch == "" assert r.commit_sha == "" assert r.pr_url == "" assert r.pr_number == 0 assert r.test_output == "" assert r.error == "" assert r.elapsed_ms == 0.0 assert r.metadata == {} @pytest.mark.unit def test_loop_result_failure(): from self_coding.self_modify.loop import LoopResult r = LoopResult(success=False, error="something broke", branch="self-modify/test") assert r.success is False assert r.error == "something broke"