"""Unit tests for devx.ci.discover_runners (deprecated wrapper). The wrapper re-exports from devx.molecule.discover_runners; these tests verify backward compatibility by importing through the wrapper and patching the canonical implementation's requests module. """ import json from pathlib import Path from unittest.mock import MagicMock, patch import click import pytest from click.testing import CliRunner from devx.ci.discover_runners import ( DEFAULT_MAX_RUNNERS, generate_indices, get_runner_count, main, query_runners, ) class TestGenerateIndices: def test_zero(self) -> None: assert generate_indices(0) == [] def test_one(self) -> None: assert generate_indices(1) == ["1"] def test_three(self) -> None: assert generate_indices(3) == ["1", "2", "3"] def test_five(self) -> None: assert generate_indices(5) == ["1", "2", "3", "4", "5"] class TestQueryRunners: @patch("devx.molecule.discover_runners.requests.get") def test_returns_total_from_all_levels(self, mock_get: MagicMock) -> None: """Runners from repo, org, and admin levels are summed.""" responses = [ MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 2}), MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 1}), MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 3}), ] mock_get.side_effect = responses result = query_runners("https://api.example.com", "token", "owner", "repo") assert result == 6 @patch("devx.molecule.discover_runners.requests.get") def test_skips_non_200(self, mock_get: MagicMock) -> None: """Non-200 responses (e.g., 403 for admin) are skipped.""" responses = [ MagicMock(status_code=200, json=lambda: {"total_count": 2}), MagicMock(status_code=200, json=lambda: {"total_count": 1}), MagicMock(status_code=403, json=lambda: {"message": "forbidden"}), ] mock_get.side_effect = responses result = query_runners("https://api.example.com", "token", "owner", "repo") assert result == 3 @patch("devx.molecule.discover_runners.requests.get") def test_handles_request_exception(self, mock_get: MagicMock) -> None: """Network errors are caught and don't crash.""" mock_get.side_effect = [ MagicMock(status_code=200, json=lambda: {"total_count": 1}), MagicMock(side_effect=__import__("requests").RequestException("network error")), MagicMock(status_code=200, json=lambda: {"total_count": 2}), ] result = query_runners("https://api.example.com", "token", "owner", "repo") assert result == 3 @patch("devx.molecule.discover_runners.requests.get") def test_all_failures_return_zero(self, mock_get: MagicMock) -> None: """When all API calls fail, returns 0.""" mock_get.side_effect = [ MagicMock(status_code=404), MagicMock(status_code=404), MagicMock(status_code=403), ] result = query_runners("https://api.example.com", "token", "owner", "repo") assert result == 0 @patch("devx.molecule.discover_runners.requests.get") def test_value_error_on_repo_level(self, mock_get: MagicMock) -> None: """JSON parse error on repo level is caught.""" responses = [ MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))), MagicMock(status_code=200, json=lambda: {"total_count": 2}), MagicMock(status_code=200, json=lambda: {"total_count": 1}), ] mock_get.side_effect = responses result = query_runners("https://api.example.com", "token", "owner", "repo") assert result == 3 @patch("devx.molecule.discover_runners.requests.get") def test_value_error_on_org_level(self, mock_get: MagicMock) -> None: """JSON parse error on org level is caught.""" responses = [ MagicMock(status_code=200, json=lambda: {"total_count": 1}), MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))), MagicMock(status_code=200, json=lambda: {"total_count": 2}), ] mock_get.side_effect = responses result = query_runners("https://api.example.com", "token", "owner", "repo") assert result == 3 @patch("devx.molecule.discover_runners.requests.get") def test_value_error_on_admin_level(self, mock_get: MagicMock) -> None: """JSON parse error on admin level is caught.""" responses = [ MagicMock(status_code=200, json=lambda: {"total_count": 1}), MagicMock(status_code=200, json=lambda: {"total_count": 2}), MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))), ] mock_get.side_effect = responses result = query_runners("https://api.example.com", "token", "owner", "repo") assert result == 3 @patch("devx.molecule.discover_runners.requests.get") def test_request_exception_on_all_levels(self, mock_get: MagicMock) -> None: """Network errors on all levels return 0.""" mock_get.side_effect = __import__("requests").RequestException("network error") result = query_runners("https://api.example.com", "token", "owner", "repo") assert result == 0 @patch("devx.molecule.discover_runners.requests.get") def test_query_runners_403_no_warning(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: """403 on instance-level runners should not produce a warning (expected without admin scope).""" responses = [ MagicMock(status_code=200, json=lambda: {"total_count": 2}), MagicMock(status_code=200, json=lambda: {"total_count": 1}), MagicMock(status_code=403, json=lambda: {"message": "forbidden"}), ] mock_get.side_effect = responses result = query_runners("https://api.example.com", "token", "owner", "repo") assert result == 3 captured = capsys.readouterr() assert "instance-level" not in captured.err @patch("devx.molecule.discover_runners.requests.get") def test_instance_level_non_403_warns(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: """Non-200, non-403 status on instance-level runners should produce a warning.""" responses = [ MagicMock(status_code=200, json=lambda: {"total_count": 1}), MagicMock(status_code=200, json=lambda: {"total_count": 1}), MagicMock(status_code=500, json=lambda: {"message": "server error"}), ] mock_get.side_effect = responses result = query_runners("https://api.example.com", "token", "owner", "repo") assert result == 2 captured = capsys.readouterr() assert "instance-level" in captured.err assert "500" in captured.err class TestGetRunnerCount: @patch("devx.molecule.discover_runners.query_runners", return_value=5) def test_uses_api_count_when_positive(self, mock_query: MagicMock) -> None: result = get_runner_count("https://api.example.com", "token", "owner", "repo") assert result == 5 @patch("devx.molecule.discover_runners.query_runners", return_value=0) @patch.dict("os.environ", {"MOLECULE_RUNNERS": "4"}) def test_falls_back_to_env_var(self, mock_query: MagicMock) -> None: result = get_runner_count("https://api.example.com", "token", "owner", "repo") assert result == 4 @patch("devx.molecule.discover_runners.query_runners", return_value=0) @patch.dict("os.environ", {"MOLECULE_RUNNERS": "invalid"}) def test_falls_back_to_default_on_invalid_env(self, mock_query: MagicMock) -> None: result = get_runner_count("https://api.example.com", "token", "owner", "repo") assert result == DEFAULT_MAX_RUNNERS @patch("devx.molecule.discover_runners.query_runners", return_value=0) @patch.dict("os.environ", {}, clear=True) def test_falls_back_to_default_when_no_env(self, mock_query: MagicMock) -> None: result = get_runner_count("https://api.example.com", "token", "owner", "repo") assert result == DEFAULT_MAX_RUNNERS @patch("devx.molecule.discover_runners.query_runners", return_value=0) @patch.dict("os.environ", {"MOLECULE_RUNNERS": "0"}) def test_env_var_zero_falls_back_to_default(self, mock_query: MagicMock) -> None: """MOLECULE_RUNNERS=0 is invalid, falls back to default.""" result = get_runner_count("https://api.example.com", "token", "owner", "repo") assert result == DEFAULT_MAX_RUNNERS @patch("devx.molecule.discover_runners.query_runners", return_value=0) @patch.dict("os.environ", {}, clear=True) def test_no_token_uses_env_var(self, mock_query: MagicMock) -> None: """When no token, skips API and uses env/default.""" with patch.dict("os.environ", {"MOLECULE_RUNNERS": "2"}): result = get_runner_count("https://api.example.com", "", "owner", "repo") assert result == 2 class TestMain: @patch("devx.molecule.discover_runners.get_runner_count", return_value=3) def test_default_output(self, mock_count: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "count=3" in result.output assert 'indices=["1", "2", "3"]' in result.output @patch("devx.molecule.discover_runners.get_runner_count", return_value=5) def test_count_only(self, mock_count: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["--count"]) assert result.exit_code == 0 assert result.output.strip() == "5" @patch("devx.molecule.discover_runners.get_runner_count", return_value=4) def test_indices_only(self, mock_count: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["--indices"]) assert result.exit_code == 0 assert json.loads(result.output.strip()) == ["1", "2", "3", "4"] @patch("devx.molecule.discover_runners.get_runner_count", return_value=1) def test_single_runner(self, mock_count: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["--indices"]) assert result.exit_code == 0 assert json.loads(result.output.strip()) == ["1"] @patch("devx.molecule.discover_runners.get_runner_count", return_value=3) def test_github_output(self, mock_count: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) runner = CliRunner() result = runner.invoke(main, ["--github-output"]) assert result.exit_code == 0 content = gh_file.read_text() assert "runner-count=3" in content assert "runner-indices=" in content @patch("devx.molecule.discover_runners.get_runner_count", return_value=3) def test_github_output_no_env(self, mock_count: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("GITHUB_OUTPUT", raising=False) runner = CliRunner() result = runner.invoke(main, ["--github-output"]) assert result.exit_code != 0 @patch("devx.molecule.discover_runners.get_runner_count", return_value=2) def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None: """When --owner and --repo are provided, env vars are not used.""" runner = CliRunner() result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"]) assert result.exit_code == 0 mock_count.assert_called_once() # Verify owner/repo passed through args, kwargs = mock_count.call_args assert "myorg" in args assert "myrepo" in args @patch("devx.molecule.discover_runners.get_ci_token", side_effect=click.ClickException("no token")) @patch("devx.molecule.discover_runners.get_runner_count", return_value=3) def test_missing_token_runs_without_api(self, mock_count: MagicMock, mock_token: MagicMock) -> None: """When no token is available, runner discovery falls back to env/default.""" runner = CliRunner() result = runner.invoke(main, ["--count"]) assert result.exit_code == 0 assert result.output.strip() == "3" args, _ = mock_count.call_args assert args[1] is None # token passed as None when missing class TestDeprecationWrapper: def test_re_exports_canonical_symbols(self) -> None: """The wrapper re-exports the canonical implementation's symbols.""" from devx.ci import discover_runners as ci_mod from devx.molecule import discover_runners as mol_mod assert ci_mod.query_runners is mol_mod.query_runners assert ci_mod.get_runner_count is mol_mod.get_runner_count assert ci_mod.generate_indices is mol_mod.generate_indices assert ci_mod.main is mol_mod.main assert ci_mod.DEFAULT_MAX_RUNNERS is mol_mod.DEFAULT_MAX_RUNNERS def test_emit_deprecation_warning(self) -> None: """_emit_deprecation_warning issues a DeprecationWarning.""" import warnings from devx.ci.discover_runners import _emit_deprecation_warning with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") _emit_deprecation_warning() assert len(caught) == 1 assert issubclass(caught[0].category, DeprecationWarning) assert "deprecated" in str(caught[0].message)