364 lines
11 KiB
Python
364 lines
11 KiB
Python
|
|
"""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"
|