Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Copyright 2026 Google LLC
# Apache-2.0 License

"""Unit tests for workflow/orchestrator.py."""

import json
import os
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from orchestrator import Orchestrator, OrchestrationError
from command_executor import CommandExecutionError


@pytest.fixture
def mock_config():
"""Returns a mock Config instance for Orchestrator tests."""
config = MagicMock()
config.repo_url = "https://github.com/test-owner/test-repo"
config.repo_name = "test-repo"
config.git_token = "secret-token"
config.pr_dir = "/tmp/pr"
config.eval_dir = "/tmp/eval"
config.pr_repo_path = "/tmp/pr/test-repo"
config.eval_repo_path = "/tmp/eval/test-repo"
config.max_attempts = 2
config.model_name = "gemini-3.5-flash"

Check warning on line 28 in tools/caretaker-agent/cloudrun/pr-generator/tests/test_orchestrator.py

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.5". Please make sure this change is appropriate to submit.
config.load_and_validate_firestore_doc.return_value = {
"github_metadata": {"owner": "test-owner", "repo": "test-repo", "issue_number": 190},
"workable_spec": {
"issue_id": "190",
"title": "Fix issue 190",
"description": "Issue description content",
},
}
return config


def test_orchestrator_init(mock_config):
"""Tests Orchestrator initialization and component setup."""
orc = Orchestrator(mock_config)
assert orc.config == mock_config
assert hasattr(orc, "agent_runner")


@patch("shutil.rmtree")
@patch("os.makedirs")
def test_setup_workspace(mock_makedirs, mock_rmtree, mock_config):
"""Tests workspace directory setup and cleanup."""
orc = Orchestrator(mock_config)
orc._setup_workspace()
assert mock_makedirs.call_count >= 2


@patch("command_executor.CommandExecutor.run")
def test_sync_or_clone_repository(mock_cmd_run, mock_config):
"""Tests repository cloning and syncing."""
mock_cmd_run.return_value = "git output"
orc = Orchestrator(mock_config)

with patch("os.path.exists", return_value=False):
orc._sync_or_clone_repository()
assert mock_cmd_run.call_count >= 1


@pytest.mark.asyncio
@patch("command_executor.CommandExecutor.run")
async def test_run_regression_checks_pass(mock_cmd_run, mock_config):
"""Tests _run_regression_checks when npm clean and npm ci succeed."""
mock_cmd_run.return_value = "clean ok"
orc = Orchestrator(mock_config)

result = await orc._run_regression_checks()
assert result is True


@pytest.mark.asyncio
@patch("preflight_filter.PreflightFilter.should_ignore_preflight_failure")
@patch("command_executor.CommandExecutor.run")
async def test_run_regression_checks_bypassed_failure(mock_cmd_run, mock_preflight_filter, mock_config):
"""Tests bypassing regression failures when preflight filter approves."""
mock_cmd_run.side_effect = CommandExecutionError(
cmd="npm run test:ci", returncode=1, stdout="FAIL src/utils/sessionCleanup.test.ts", stderr=""
)
mock_preflight_filter.return_value = True

orc = Orchestrator(mock_config)
result = await orc._run_regression_checks()
assert result is True


@pytest.mark.asyncio
@patch("preflight_filter.PreflightFilter.should_ignore_preflight_failure")
@patch("command_executor.CommandExecutor.run")
async def test_run_regression_checks_unapproved_failure(mock_cmd_run, mock_preflight_filter, mock_config):
"""Tests handling of unapproved regression failures."""
mock_cmd_run.side_effect = CommandExecutionError(
cmd="npm run test:ci", returncode=1, stdout="FAIL src/auth/login.test.ts", stderr=""
)
mock_preflight_filter.return_value = False

orc = Orchestrator(mock_config)
with patch("builtins.open", MagicMock()):
result = await orc._run_regression_checks()
assert result is False


@patch("shutil.copyfile")
@patch("os.path.exists")
def test_save_feedback_to_coding_workspace(mock_exists, mock_copyfile, mock_config):
"""Tests copying pr_feedback.md from eval workspace to PR workspace."""
mock_exists.return_value = True
orc = Orchestrator(mock_config)

orc._save_feedback_to_coding_workspace()
mock_copyfile.assert_called_once_with(
os.path.join(mock_config.eval_repo_path, "pr_feedback.md"),
os.path.join(mock_config.pr_repo_path, "pr_feedback.md"),
)


@pytest.mark.asyncio
@patch("orchestrator.acquire_lock", return_value="CLAIMED")
@patch("orchestrator.release_lock", return_value=True)
@patch("command_executor.CommandExecutor.run")
@patch("orchestrator.Orchestrator._setup_workspace")
@patch("orchestrator.Orchestrator._sync_or_clone_repository")
@patch("orchestrator.Orchestrator._run_regression_checks")
@patch("github_client.GitHubClient.create_pull_request")
async def test_run_loop_success_pr_created(
mock_create_pr, mock_regression, mock_sync, mock_setup, mock_cmd_run, mock_release_lock, mock_acquire_lock, mock_config
):
"""Tests complete successful orchestrator run loop resulting in PR creation."""
mock_regression.return_value = True
mock_create_pr.return_value = "28"

def cmd_side_effect(cmd, *args, **kwargs):
cmd_str = str(cmd)
if "diff --stat" in cmd_str:
return "1 file changed, 5 insertions(+), 5 deletions(-)"
if "diff" in cmd_str:
return "diff --git a/file.py b/file.py\n+new line"
if "status" in cmd_str:
return "modified: file.py"
return "ok"

mock_cmd_run.side_effect = cmd_side_effect

orc = Orchestrator(mock_config)
orc.agent_runner.run_agent = AsyncMock()
orc.agent_runner.run_agent.return_value = ("Coding agent completed code changes.", [])

with patch("os.path.exists", return_value=True), \
patch("builtins.open", MagicMock()):
with patch.object(orc, "_run_evaluation", AsyncMock(return_value="APPROVED")):
await orc.run()

mock_create_pr.assert_called_once()


@pytest.mark.asyncio
@patch("orchestrator.acquire_lock", return_value="CLAIMED")
@patch("orchestrator.release_lock", return_value=True)
@patch("command_executor.CommandExecutor.run")
@patch("orchestrator.Orchestrator._setup_workspace")
@patch("orchestrator.Orchestrator._sync_or_clone_repository")
async def test_run_loop_max_attempts_exceeded(mock_sync, mock_setup, mock_cmd_run, mock_release_lock, mock_acquire_lock, mock_config):
"""Tests that run loop finishes and releases lock when max repair attempts are exhausted."""
def cmd_side_effect(cmd, *args, **kwargs):
cmd_str = str(cmd)
if "diff" in cmd_str:
return "diff --git a/file.py b/file.py\n+new line"
return "ok"

mock_cmd_run.side_effect = cmd_side_effect

orc = Orchestrator(mock_config)
orc.agent_runner.run_agent = AsyncMock(
return_value=("Code generation output", [])
)

with patch("os.path.exists", return_value=False), \
patch.object(orc, "_run_evaluation", AsyncMock(return_value="REJECTED")):
await orc.run()

mock_release_lock.assert_called_once()
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Copyright 2026 Google LLC
# Apache-2.0 License

"""Unit tests for workflow/worker.py."""

import logging
import sys
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from worker import IgnoreRawWsMsgFilter, main, setup_logging
from orchestrator import OrchestrationError


def test_ignore_raw_ws_msg_filter():
"""Tests that IgnoreRawWsMsgFilter filters out RAW WS MSG log records."""
msg_filter = IgnoreRawWsMsgFilter()

record_ws = logging.LogRecord(
name="test", level=logging.INFO, pathname="", lineno=0,
msg="RAW WS MSG: websocket data packet", args=(), exc_info=None
)
assert msg_filter.filter(record_ws) is False

record_normal = logging.LogRecord(
name="test", level=logging.INFO, pathname="", lineno=0,
msg="Normal execution status log", args=(), exc_info=None
)
assert msg_filter.filter(record_normal) is True


@patch("logging.basicConfig")
def test_setup_logging(mock_basic_config):
"""Tests that setup_logging configures root logger handlers correctly."""
setup_logging()
mock_basic_config.assert_called_once()
kwargs = mock_basic_config.call_args[1]
assert kwargs["level"] == logging.INFO
assert len(kwargs["handlers"]) == 1
assert isinstance(kwargs["handlers"][0], logging.StreamHandler)


@pytest.mark.asyncio
@patch("worker.Config")
@patch("worker.Orchestrator")
async def test_worker_main_success(mock_orchestrator_cls, mock_config_cls):
"""Tests successful worker execution lifecycle."""
mock_config = MagicMock()
mock_config_cls.return_value = mock_config

mock_orchestrator = MagicMock()
mock_orchestrator.run = AsyncMock(return_value="PR_CREATED")
mock_orchestrator_cls.return_value = mock_orchestrator

await main()

mock_config_cls.assert_called_once()
mock_orchestrator_cls.assert_called_once_with(mock_config)
mock_orchestrator.run.assert_called_once()


@pytest.mark.asyncio
@patch("worker.Config")
@patch("worker.Orchestrator")
async def test_worker_main_orchestration_error(mock_orchestrator_cls, mock_config_cls):
"""Tests that OrchestrationError results in sys.exit(1)."""
mock_orchestrator = MagicMock()
mock_orchestrator.run = AsyncMock(side_effect=OrchestrationError("Fatal loop limit"))
mock_orchestrator_cls.return_value = mock_orchestrator

with pytest.raises(SystemExit) as exc_info:
await main()

assert exc_info.value.code == 1


@pytest.mark.asyncio
@patch("worker.Config")
@patch("worker.Orchestrator")
async def test_worker_main_unexpected_exception(mock_orchestrator_cls, mock_config_cls):
"""Tests that unhandled exceptions result in sys.exit(4)."""
mock_orchestrator = MagicMock()
mock_orchestrator.run = AsyncMock(side_effect=RuntimeError("System crash"))
mock_orchestrator_cls.return_value = mock_orchestrator

with pytest.raises(SystemExit) as exc_info:
await main()

assert exc_info.value.code == 4
Loading
Loading