"""Unit tests for scripts/ci/molecule_ci_guard.py.""" from __future__ import annotations import os import subprocess # nosec B404 import time import xml.etree.ElementTree as ET from pathlib import Path from unittest.mock import MagicMock, patch import click import pytest import requests from devx.molecule.molecule_ci_guard import ( any_other_runner_failed, build_env_for_pair, build_molecule_cmd, cli, get_running_jobs, parse_pair, poll_for_other_failures, resolve_role_dir, write_junit_report, ) class TestGetRunningJobs: def test_returns_jobs(self) -> None: with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get: mock_response = MagicMock() mock_response.json.return_value = { "jobs": [ {"name": "molecule-tests (0)", "conclusion": "success"}, {"name": "molecule-tests (1)", "conclusion": "failure"}, ] } mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123) assert len(jobs) == 2 mock_get.assert_called_once() def test_raises_on_request_error(self) -> None: with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get: mock_get.side_effect = requests.RequestException("boom") with pytest.raises(requests.RequestException): get_running_jobs("https://gitea.example", "owner", "repo", "token", 123) class TestAnyOtherRunnerFailed: def test_detects_other_failure(self) -> None: jobs = [ {"name": "molecule-tests (0)", "conclusion": "success"}, {"name": "molecule-tests (1)", "conclusion": "failure"}, {"name": "molecule-tests (2)", "conclusion": "running"}, ] assert any_other_runner_failed(jobs, "molecule-tests", 0) is True def test_ignores_current_runner(self) -> None: jobs = [ {"name": "molecule-tests (0)", "conclusion": "failure"}, {"name": "molecule-tests (1)", "conclusion": "success"}, ] assert any_other_runner_failed(jobs, "molecule-tests", 0) is False def test_ignores_non_molecule_jobs(self) -> None: jobs = [ {"name": "quality", "conclusion": "failure"}, {"name": "molecule-tests (1)", "conclusion": "success"}, ] assert any_other_runner_failed(jobs, "molecule-tests", 0) is False class TestBuildMoleculeCmd: def test_default_scenario(self) -> None: assert build_molecule_cmd("default") == ["molecule", "test"] def test_named_scenario(self) -> None: assert build_molecule_cmd("lifecycle") == ["molecule", "test", "-s", "lifecycle"] class TestBuildEnvForPair: def test_with_command(self) -> None: env = build_env_for_pair("default|ubuntu-2204|img:latest|/lib/systemd/systemd", {}) assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204" assert env["MOLECULE_PLATFORM_IMAGE"] == "img:latest" assert env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd" assert env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] == "true" def test_without_command(self) -> None: env = build_env_for_pair("default|ubuntu-2204|img:latest|", {}) assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204" assert "MOLECULE_PLATFORM_COMMAND" not in env def test_without_command_removes_existing(self) -> None: env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"}) assert "MOLECULE_PLATFORM_COMMAND" not in env class TestPollForOtherFailures: def test_sets_failed_event_when_other_runner_fails(self) -> None: stop_event = MagicMock() failed_event = MagicMock() def side_effect(*args, **kwargs): if stop_event.wait.call_count < 1: return [ {"name": "molecule-tests (0)", "conclusion": "success"}, {"name": "molecule-tests (1)", "conclusion": "failure"}, ] return [] with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs: mock_get_jobs.side_effect = side_effect stop_event.is_set.side_effect = [False, False] stop_event.wait.return_value = True poll_for_other_failures( "https://gitea.example", "owner", "repo", "token", 123, "molecule-tests", 0, stop_event, failed_event, ) failed_event.set.assert_called_once() def test_poll_warns_on_api_error(self) -> None: stop_event = MagicMock() failed_event = MagicMock() with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs: mock_get_jobs.side_effect = requests.RequestException("boom") stop_event.is_set.side_effect = [False, True] stop_event.wait.return_value = True poll_for_other_failures( "https://gitea.example", "owner", "repo", "token", 123, "molecule-tests", 0, stop_event, failed_event, ) failed_event.set.assert_not_called() class TestCli: def test_all_pass(self) -> None: from click.testing import CliRunner with ( patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run, patch("time.sleep"), ): proc = MagicMock() proc.poll.return_value = 0 proc.returncode = 0 mock_popen.return_value = proc mock_run.return_value = MagicMock(returncode=0) runner = CliRunner() result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) assert result.exit_code == 0 assert "All molecule tests passed" in result.output # Verify Docker prune was called between scenarios mock_run.assert_called_once_with( ["docker", "system", "prune", "-af", "--volumes"], check=False, capture_output=True, timeout=60, ) def test_invalid_pair_format_raises(self) -> None: """Pair with fewer than 2 parts should raise.""" from click.testing import CliRunner runner = CliRunner() result = runner.invoke(cli, ["invalid_no_pipe"]) assert result.exit_code != 0 assert "Invalid pair format" in result.output def test_failure_exits_nonzero(self) -> None: from click.testing import CliRunner with ( patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("time.sleep"), ): proc = MagicMock() proc.poll.return_value = 1 proc.returncode = 1 mock_popen.return_value = proc runner = CliRunner() result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) assert result.exit_code == 1 assert "FAILED" in result.output def test_exits_before_starting_when_already_failed(self) -> None: from click.testing import CliRunner with ( patch.dict( os.environ, { "GITEA_URL": "https://gitea.example", "REPO_TOKEN": "token", "RUN_ID": "123", "JOB_NAME": "molecule-tests", "MATRIX_INDEX": "0", "GITEA_REPOSITORY": "oblachno-oss/grm", "PATH": os.environ.get("PATH", ""), }, clear=True, ), patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01), patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs, patch("time.sleep"), ): mock_get_jobs.return_value = [ {"name": "molecule-tests (0)", "conclusion": "running"}, {"name": "molecule-tests (1)", "conclusion": "failure"}, ] proc = MagicMock() proc.poll.return_value = 0 proc.returncode = 0 mock_popen.return_value = proc runner = CliRunner() result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) assert result.exit_code == 1 assert "Another molecule runner failed" in result.output def test_keyboard_interrupt_kills_process(self) -> None: from click.testing import CliRunner with ( patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("time.sleep", side_effect=KeyboardInterrupt), patch("os.killpg") as mock_killpg, patch("os.getpgid") as mock_getpgid, ): mock_getpgid.return_value = 123 proc = MagicMock() proc.poll.return_value = None proc.wait.return_value = 0 mock_popen.return_value = proc runner = CliRunner() result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) assert result.exit_code == 1 mock_killpg.assert_called() def test_exits_when_other_runner_fails(self) -> None: from click.testing import CliRunner real_sleep = time.sleep call_count = [0] def get_jobs_side_effect(*args, **kwargs): call_count[0] += 1 if call_count[0] < 2: return [{"name": "molecule-tests (1)", "conclusion": "running"}] return [ {"name": "molecule-tests (0)", "conclusion": "running"}, {"name": "molecule-tests (1)", "conclusion": "failure"}, ] with ( patch.dict( os.environ, { "GITEA_URL": "https://gitea.example", "REPO_TOKEN": "token", "RUN_ID": "123", "JOB_NAME": "molecule-tests", "MATRIX_INDEX": "0", "GITEA_REPOSITORY": "oblachno-oss/grm", "PATH": os.environ.get("PATH", ""), }, clear=True, ), patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01), patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("os.killpg") as mock_killpg, patch("os.getpgid") as mock_getpgid, patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), ): mock_getpgid.return_value = 123 proc = MagicMock() proc.poll.return_value = None proc.wait.return_value = 0 mock_popen.return_value = proc runner = CliRunner() result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) assert result.exit_code == 1 mock_killpg.assert_called() def test_default_owner_repo_fallback(self) -> None: from click.testing import CliRunner real_sleep = time.sleep with ( patch.dict( os.environ, { "GITEA_URL": "https://gitea.example", "REPO_TOKEN": "token", "RUN_ID": "123", "JOB_NAME": "molecule-tests", "MATRIX_INDEX": "0", "GITEA_REPOSITORY": "invalid", "PATH": os.environ.get("PATH", ""), }, clear=True, ), patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01), patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run, patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs, patch("time.sleep", side_effect=lambda x: real_sleep(0.05)), ): mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}] proc = MagicMock() proc.poll.return_value = 0 proc.returncode = 0 mock_popen.return_value = proc mock_run.return_value = MagicMock(returncode=0) runner = CliRunner() result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) assert result.exit_code == 0 def test_exits_when_other_runner_fails_with_process_lookup_error(self) -> None: from click.testing import CliRunner real_sleep = time.sleep call_count = [0] def get_jobs_side_effect(*args, **kwargs): call_count[0] += 1 if call_count[0] < 2: return [{"name": "molecule-tests (1)", "conclusion": "running"}] return [ {"name": "molecule-tests (0)", "conclusion": "running"}, {"name": "molecule-tests (1)", "conclusion": "failure"}, ] with ( patch.dict( os.environ, { "GITEA_URL": "https://gitea.example", "REPO_TOKEN": "token", "RUN_ID": "123", "JOB_NAME": "molecule-tests", "MATRIX_INDEX": "0", "GITEA_REPOSITORY": "oblachno-oss/grm", "PATH": os.environ.get("PATH", ""), }, clear=True, ), patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01), patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("os.killpg") as mock_killpg, patch("os.getpgid") as mock_getpgid, patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), ): mock_getpgid.return_value = 123 mock_killpg.side_effect = ProcessLookupError("no such process") proc = MagicMock() proc.poll.return_value = None proc.wait.return_value = 0 mock_popen.return_value = proc runner = CliRunner() result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) assert result.exit_code == 1 def test_exits_when_other_runner_fails_with_timeout(self) -> None: from click.testing import CliRunner real_sleep = time.sleep call_count = [0] def get_jobs_side_effect(*args, **kwargs): call_count[0] += 1 if call_count[0] < 2: return [{"name": "molecule-tests (1)", "conclusion": "running"}] return [ {"name": "molecule-tests (0)", "conclusion": "running"}, {"name": "molecule-tests (1)", "conclusion": "failure"}, ] with ( patch.dict( os.environ, { "GITEA_URL": "https://gitea.example", "REPO_TOKEN": "token", "RUN_ID": "123", "JOB_NAME": "molecule-tests", "MATRIX_INDEX": "0", "GITEA_REPOSITORY": "oblachno-oss/grm", "PATH": os.environ.get("PATH", ""), }, clear=True, ), patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01), patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("os.killpg") as mock_killpg, patch("os.getpgid") as mock_getpgid, patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), ): mock_getpgid.return_value = 123 mock_killpg.side_effect = [None, ProcessLookupError("no such process")] proc = MagicMock() proc.poll.return_value = None proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)] mock_popen.return_value = proc runner = CliRunner() result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) assert result.exit_code == 1 def test_main_module_block() -> None: import devx.molecule.molecule_ci_guard as mg with open(mg.__file__) as f: source = f.read() source = source.replace('if __name__ == "__main__":\n cli()\n', "") namespace = dict(mg.__dict__) exec(compile(source, mg.__file__, "exec"), namespace) assert callable(namespace["cli"]) class TestParsePair: def test_single_role_4_part(self) -> None: role, scenario, name, image, cmd = parse_pair("default|ubuntu-2204|ubuntu:22.04|") assert role == "" assert scenario == "default" assert name == "ubuntu-2204" assert image == "ubuntu:22.04" assert cmd == "" def test_multi_role_5_part(self) -> None: role, scenario, name, image, cmd = parse_pair("gitea-runner|default|ubuntu-2204|ubuntu:22.04|") assert role == "gitea-runner" assert scenario == "default" assert name == "ubuntu-2204" assert image == "ubuntu:22.04" assert cmd == "" def test_multi_role_with_command(self) -> None: role, scenario, name, image, cmd = parse_pair( "docker-base|lifecycle|archlinux|archlinux:latest|/usr/lib/systemd/systemd" ) assert role == "docker-base" assert scenario == "lifecycle" assert cmd == "/usr/lib/systemd/systemd" def test_invalid_pair_raises(self) -> None: with pytest.raises(click.ClickException, match="Invalid pair format"): parse_pair("only|two|parts") def test_too_many_parts_raises(self) -> None: with pytest.raises(click.ClickException, match="Invalid pair format"): parse_pair("a|b|c|d|e|f") class TestResolveRoleDir: def test_multi_role_with_roles_root(self, tmp_path: Path) -> None: roles_root = tmp_path / "ansible" / "roles" roles_root.mkdir(parents=True) result = resolve_role_dir("gitea-runner", roles_root, tmp_path) assert result == roles_root / "gitea-runner" def test_multi_role_default_roles_root(self, tmp_path: Path) -> None: result = resolve_role_dir("docker-base", None, tmp_path) assert result == tmp_path / "ansible" / "roles" / "docker-base" def test_single_role_uses_default(self, tmp_path: Path) -> None: result = resolve_role_dir("", None, tmp_path) assert result == tmp_path / "ansible" / "roles" / "gitea-runner" class TestWriteJunitReport: def test_writes_report_with_passing_tests(self, tmp_path: Path) -> None: output = str(tmp_path / "junit-results" / "runner-1.xml") testcases = [ {"role": "gitea-runner", "scenario": "default", "time": 5.2, "passed": True, "error": None}, {"role": "docker-base", "scenario": "lifecycle", "time": 3.1, "passed": True, "error": None}, ] write_junit_report(output, testcases, 1) tree = ET.parse(output) root = tree.getroot() assert root.get("tests") == "2" assert root.get("failures") == "0" assert len(root) == 2 def test_writes_report_with_failures(self, tmp_path: Path) -> None: output = str(tmp_path / "runner-2.xml") testcases = [ {"role": "", "scenario": "default", "time": 1.0, "passed": False, "error": "Exit code: 1"}, ] write_junit_report(output, testcases, 2) tree = ET.parse(output) root = tree.getroot() assert root.get("tests") == "1" assert root.get("failures") == "1" failure = root[0][0] assert failure.tag == "failure" assert failure.text == "Exit code: 1" def test_creates_parent_directory(self, tmp_path: Path) -> None: output = str(tmp_path / "deep" / "nested" / "dir" / "runner.xml") write_junit_report(output, [], 0) assert Path(output).exists() class TestCliMultiRole: def test_multi_role_pair_passes(self, tmp_path: Path) -> None: from click.testing import CliRunner roles_root = tmp_path / "ansible" / "roles" (roles_root / "gitea-runner").mkdir(parents=True) with ( patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run, patch("time.sleep"), ): proc = MagicMock() proc.poll.return_value = 0 proc.returncode = 0 mock_popen.return_value = proc mock_run.return_value = MagicMock(returncode=0) runner = CliRunner() result = runner.invoke( cli, ["--roles-root", str(roles_root), "gitea-runner|default|ubuntu-2204|ubuntu:22.04|"], ) assert result.exit_code == 0 assert "All molecule tests passed" in result.output def test_junit_output_written(self, tmp_path: Path) -> None: from click.testing import CliRunner roles_root = tmp_path / "ansible" / "roles" (roles_root / "gitea-runner").mkdir(parents=True) junit_path = str(tmp_path / "junit-results" / "runner-1.xml") with ( patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run, patch("time.sleep"), ): proc = MagicMock() proc.poll.return_value = 0 proc.returncode = 0 mock_popen.return_value = proc mock_run.return_value = MagicMock(returncode=0) runner = CliRunner() result = runner.invoke( cli, [ "--roles-root", str(roles_root), "--junit-output", junit_path, "gitea-runner|default|ubuntu-2204|ubuntu:22.04|", ], ) assert result.exit_code == 0 assert Path(junit_path).exists() def test_junit_output_on_failure(self, tmp_path: Path) -> None: from click.testing import CliRunner roles_root = tmp_path / "ansible" / "roles" (roles_root / "gitea-runner").mkdir(parents=True) junit_path = str(tmp_path / "junit-results" / "runner-1.xml") with ( patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("time.sleep"), ): proc = MagicMock() proc.poll.return_value = 1 proc.returncode = 1 mock_popen.return_value = proc runner = CliRunner() result = runner.invoke( cli, [ "--roles-root", str(roles_root), "--junit-output", junit_path, "gitea-runner|default|ubuntu-2204|ubuntu:22.04|", ], ) assert result.exit_code == 1 assert Path(junit_path).exists() tree = ET.parse(junit_path) assert tree.getroot().get("failures") == "1" def test_junit_output_on_cancellation(self, tmp_path: Path) -> None: """JUnit report is written when a runner is cancelled by another runner's failure.""" from click.testing import CliRunner real_sleep = time.sleep roles_root = tmp_path / "ansible" / "roles" (roles_root / "gitea-runner").mkdir(parents=True) junit_path = str(tmp_path / "junit-results" / "runner-1.xml") call_count = [0] def get_jobs_side_effect(*args, **kwargs): call_count[0] += 1 if call_count[0] < 2: return [{"name": "molecule-tests (1)", "conclusion": "running"}] return [ {"name": "molecule-tests (0)", "conclusion": "running"}, {"name": "molecule-tests (1)", "conclusion": "failure"}, ] with ( patch.dict( os.environ, { "GITEA_URL": "https://gitea.example", "REPO_TOKEN": "token", "RUN_ID": "123", "JOB_NAME": "molecule-tests", "MATRIX_INDEX": "0", "GITEA_REPOSITORY": "oblachno-oss/infra", "PATH": os.environ.get("PATH", ""), }, clear=True, ), patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01), patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("os.killpg"), patch("os.getpgid") as mock_getpgid, patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), ): mock_getpgid.return_value = 123 proc = MagicMock() proc.poll.return_value = None proc.wait.return_value = 0 mock_popen.return_value = proc runner = CliRunner() result = runner.invoke( cli, [ "--roles-root", str(roles_root), "--junit-output", junit_path, "gitea-runner|default|ubuntu-2204|ubuntu:22.04|", ], ) assert result.exit_code == 1 assert Path(junit_path).exists() tree = ET.parse(junit_path) root = tree.getroot() assert root.get("failures") == "1" # The failure message should mention cancellation failure = root[0][0] assert "Cancelled" in (failure.text or "")