Public Access
DEVX-155: refactor: extract wait_for_checks, consolidate ansible_checks, deprecate ci/discover_runners
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""Unit tests for devx.tools.ansible_checks._shared."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from devx.tools.ansible_checks._shared import (
|
||||
DEFAULT_ANSIBLE_DIRS,
|
||||
AnsibleFileFinder,
|
||||
AnsibleYAMLParser,
|
||||
ViolationReporter,
|
||||
)
|
||||
|
||||
|
||||
class TestAnsibleFileFinder:
|
||||
def test_find_task_files_single_yaml(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text("tasks: []")
|
||||
assert AnsibleFileFinder.find_task_files(f) == [f]
|
||||
|
||||
def test_find_task_files_single_non_yaml(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.txt"
|
||||
f.write_text("hello")
|
||||
assert AnsibleFileFinder.find_task_files(f) == []
|
||||
|
||||
def test_find_task_files_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a.yml").write_text("tasks: []")
|
||||
(tmp_path / "b.yaml").write_text("tasks: []")
|
||||
(tmp_path / "c.txt").write_text("hello")
|
||||
result = AnsibleFileFinder.find_task_files(tmp_path)
|
||||
assert len(result) == 2
|
||||
assert all(f.suffix in (".yml", ".yaml") for f in result)
|
||||
|
||||
def test_find_task_files_skip_molecule(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a.yml").write_text("tasks: []")
|
||||
mol = tmp_path / "molecule" / "default"
|
||||
mol.mkdir(parents=True)
|
||||
(mol / "main.yml").write_text("tasks: []")
|
||||
result = AnsibleFileFinder.find_task_files(tmp_path, skip_molecule=True)
|
||||
assert len(result) == 1
|
||||
assert "molecule" not in result[0].parts
|
||||
|
||||
def test_find_task_files_include_molecule(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a.yml").write_text("tasks: []")
|
||||
mol = tmp_path / "molecule" / "default"
|
||||
mol.mkdir(parents=True)
|
||||
(mol / "main.yml").write_text("tasks: []")
|
||||
result = AnsibleFileFinder.find_task_files(tmp_path, skip_molecule=False)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_find_task_files_nonexistent(self, tmp_path: Path) -> None:
|
||||
assert AnsibleFileFinder.find_task_files(tmp_path / "nonexistent") == []
|
||||
|
||||
def test_find_yaml_files_single_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.txt"
|
||||
f.write_text("hello")
|
||||
# find_yaml_files accepts any single file (no suffix check)
|
||||
assert AnsibleFileFinder.find_yaml_files(f) == [f]
|
||||
|
||||
def test_find_yaml_files_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a.yml").write_text("tasks: []")
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "b.yaml").write_text("tasks: []")
|
||||
result = AnsibleFileFinder.find_yaml_files(tmp_path)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_find_yaml_files_skip_molecule(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a.yml").write_text("tasks: []")
|
||||
mol = tmp_path / "molecule" / "default"
|
||||
mol.mkdir(parents=True)
|
||||
(mol / "main.yml").write_text("tasks: []")
|
||||
result = AnsibleFileFinder.find_yaml_files(tmp_path, skip_molecule=True)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_find_yaml_files_include_molecule(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a.yml").write_text("tasks: []")
|
||||
mol = tmp_path / "molecule" / "default"
|
||||
mol.mkdir(parents=True)
|
||||
(mol / "main.yml").write_text("tasks: []")
|
||||
result = AnsibleFileFinder.find_yaml_files(tmp_path, skip_molecule=False)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_find_task_and_playbook_files(self, tmp_path: Path) -> None:
|
||||
role = tmp_path / "roles" / "myrole"
|
||||
(role / "tasks").mkdir(parents=True)
|
||||
(role / "tasks" / "main.yml").write_text("tasks: []")
|
||||
pb = tmp_path / "playbooks"
|
||||
pb.mkdir()
|
||||
(pb / "deploy.yml").write_text("tasks: []")
|
||||
(tmp_path / "random.yml").write_text("tasks: []")
|
||||
result = AnsibleFileFinder.find_task_and_playbook_files(tmp_path)
|
||||
# Should find tasks/main.yml and playbooks/deploy.yml, not random.yml
|
||||
names = [f.name for f in result]
|
||||
assert "main.yml" in names
|
||||
assert "deploy.yml" in names
|
||||
assert "random.yml" not in names
|
||||
|
||||
def test_find_task_and_playbook_files_skip_molecule(self, tmp_path: Path) -> None:
|
||||
role = tmp_path / "roles" / "myrole"
|
||||
(role / "tasks").mkdir(parents=True)
|
||||
(role / "tasks" / "main.yml").write_text("tasks: []")
|
||||
mol = role / "molecule" / "default" / "tasks"
|
||||
mol.mkdir(parents=True)
|
||||
(mol / "main.yml").write_text("tasks: []")
|
||||
result = AnsibleFileFinder.find_task_and_playbook_files(tmp_path, skip_molecule=True)
|
||||
assert len(result) == 1
|
||||
assert "molecule" not in result[0].parts
|
||||
|
||||
|
||||
class TestAnsibleYAMLParser:
|
||||
def test_parse_file_valid(self) -> None:
|
||||
content = "---\n- name: test\n shell: echo hi\n"
|
||||
docs = AnsibleYAMLParser.parse_file(content)
|
||||
assert len(docs) == 1
|
||||
assert isinstance(docs[0], list)
|
||||
|
||||
def test_parse_file_multi_doc(self) -> None:
|
||||
content = "---\n- a\n---\n- b\n"
|
||||
docs = AnsibleYAMLParser.parse_file(content)
|
||||
assert len(docs) == 2
|
||||
|
||||
def test_parse_file_empty_docs_filtered(self) -> None:
|
||||
content = "---\n- a\n---\n\n"
|
||||
docs = AnsibleYAMLParser.parse_file(content)
|
||||
assert len(docs) == 1
|
||||
|
||||
def test_parse_file_yaml_error(self) -> None:
|
||||
content = "{{ invalid: ["
|
||||
docs = AnsibleYAMLParser.parse_file(content)
|
||||
assert docs == []
|
||||
|
||||
def test_iter_tasks_bare_list(self) -> None:
|
||||
doc = [{"name": "task1", "shell": "echo hi"}, {"name": "task2", "shell": "echo bye"}]
|
||||
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
|
||||
assert len(tasks) == 2
|
||||
assert tasks[0][0]["name"] == "task1"
|
||||
assert tasks[0][1] == 1
|
||||
assert tasks[1][1] == 2
|
||||
|
||||
def test_iter_tasks_play_dict(self) -> None:
|
||||
doc = {"hosts": "all", "tasks": [{"name": "task1", "shell": "echo hi"}]}
|
||||
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0][0]["name"] == "task1"
|
||||
|
||||
def test_iter_tasks_play_with_pre_post_handlers(self) -> None:
|
||||
doc = {
|
||||
"hosts": "all",
|
||||
"pre_tasks": [{"name": "pre", "shell": "echo pre"}],
|
||||
"tasks": [{"name": "main", "shell": "echo main"}],
|
||||
"post_tasks": [{"name": "post", "shell": "echo post"}],
|
||||
"handlers": [{"name": "handler", "shell": "echo handler"}],
|
||||
}
|
||||
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
|
||||
assert len(tasks) == 4
|
||||
names = [t[0]["name"] for t in tasks]
|
||||
# Order: tasks, pre_tasks, post_tasks, handlers (as defined in _iter_play_sections)
|
||||
assert names == ["main", "pre", "post", "handler"]
|
||||
|
||||
def test_iter_tasks_block(self) -> None:
|
||||
doc = [{"name": "outer", "block": [{"name": "inner", "shell": "echo hi"}]}]
|
||||
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
|
||||
# outer is not a play (no task sections) → yielded as bare task
|
||||
# inner is yielded from block
|
||||
assert len(tasks) == 2
|
||||
assert tasks[0][0]["name"] == "outer"
|
||||
assert tasks[1][0]["name"] == "inner"
|
||||
|
||||
def test_iter_tasks_block_in_play_section(self) -> None:
|
||||
"""Block tasks within a play's tasks section are yielded."""
|
||||
doc = {
|
||||
"hosts": "all",
|
||||
"tasks": [
|
||||
{"name": "outer", "block": [{"name": "inner", "shell": "echo hi"}]},
|
||||
],
|
||||
}
|
||||
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
|
||||
assert len(tasks) == 2
|
||||
assert tasks[0][0]["name"] == "outer"
|
||||
assert tasks[1][0]["name"] == "inner"
|
||||
|
||||
def test_iter_tasks_play_list(self) -> None:
|
||||
doc = [{"hosts": "all", "tasks": [{"name": "task1", "shell": "echo hi"}]}]
|
||||
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0][0]["name"] == "task1"
|
||||
|
||||
def test_iter_tasks_non_dict_items_skipped(self) -> None:
|
||||
doc = ["string", 42, {"name": "task1", "shell": "echo hi"}]
|
||||
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
|
||||
assert len(tasks) == 1
|
||||
|
||||
|
||||
class TestViolationReporter:
|
||||
def test_format_violation_with_line(self, tmp_path: Path) -> None:
|
||||
result = ViolationReporter.format_violation(tmp_path / "foo.yml", tmp_path, 42, "bad")
|
||||
assert result == "foo.yml:42 — bad"
|
||||
|
||||
def test_format_violation_without_line(self, tmp_path: Path) -> None:
|
||||
result = ViolationReporter.format_violation(tmp_path / "foo.yml", tmp_path, None, "bad")
|
||||
assert result == "foo.yml — bad"
|
||||
|
||||
def test_format_violation_not_relative(self, tmp_path: Path) -> None:
|
||||
other = Path("/other/path")
|
||||
result = ViolationReporter.format_violation(other, tmp_path, 1, "bad")
|
||||
assert str(other) in result
|
||||
assert "bad" in result
|
||||
|
||||
def test_report_no_violations(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
ViolationReporter.report([], "test-tool")
|
||||
captured = capsys.readouterr()
|
||||
assert "OK" in captured.out
|
||||
assert "test-tool" in captured.out
|
||||
|
||||
def test_report_with_violations(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
ViolationReporter.report(["v1", "v2"], "test-tool")
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "FAIL" in captured.out
|
||||
assert "v1" in captured.out
|
||||
assert "v2" in captured.out
|
||||
|
||||
|
||||
class TestDefaultAnsibleDirs:
|
||||
def test_is_tuple(self) -> None:
|
||||
assert isinstance(DEFAULT_ANSIBLE_DIRS, tuple)
|
||||
|
||||
def test_contains_expected(self) -> None:
|
||||
assert "ansible/roles" in DEFAULT_ANSIBLE_DIRS
|
||||
assert "ansible/playbooks" in DEFAULT_ANSIBLE_DIRS
|
||||
@@ -150,6 +150,13 @@ class TestCiCommands:
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.ci.validate_commit_msg", ["msg"])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_ci_wait_for_checks(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["ci", "wait-for-checks", "--", "--job-name", "molecule-tests"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.ci.wait_for_checks", ["--job-name", "molecule-tests"])
|
||||
|
||||
|
||||
class TestToolsCommands:
|
||||
@patch("devx.cli._run_module")
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Unit tests for scripts/ci/discover_runners.py."""
|
||||
"""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
|
||||
@@ -32,7 +37,7 @@ class TestGenerateIndices:
|
||||
|
||||
|
||||
class TestQueryRunners:
|
||||
@patch("devx.ci.discover_runners.requests.get")
|
||||
@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 = [
|
||||
@@ -44,7 +49,7 @@ class TestQueryRunners:
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 6
|
||||
|
||||
@patch("devx.ci.discover_runners.requests.get")
|
||||
@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 = [
|
||||
@@ -56,7 +61,7 @@ class TestQueryRunners:
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 3
|
||||
|
||||
@patch("devx.ci.discover_runners.requests.get")
|
||||
@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 = [
|
||||
@@ -67,7 +72,7 @@ class TestQueryRunners:
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 3
|
||||
|
||||
@patch("devx.ci.discover_runners.requests.get")
|
||||
@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 = [
|
||||
@@ -78,7 +83,7 @@ class TestQueryRunners:
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 0
|
||||
|
||||
@patch("devx.ci.discover_runners.requests.get")
|
||||
@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 = [
|
||||
@@ -90,7 +95,7 @@ class TestQueryRunners:
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 3
|
||||
|
||||
@patch("devx.ci.discover_runners.requests.get")
|
||||
@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 = [
|
||||
@@ -102,7 +107,7 @@ class TestQueryRunners:
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 3
|
||||
|
||||
@patch("devx.ci.discover_runners.requests.get")
|
||||
@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 = [
|
||||
@@ -114,14 +119,14 @@ class TestQueryRunners:
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 3
|
||||
|
||||
@patch("devx.ci.discover_runners.requests.get")
|
||||
@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.ci.discover_runners.requests.get")
|
||||
@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 = [
|
||||
@@ -135,7 +140,7 @@ class TestQueryRunners:
|
||||
captured = capsys.readouterr()
|
||||
assert "instance-level" not in captured.err
|
||||
|
||||
@patch("devx.ci.discover_runners.requests.get")
|
||||
@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 = [
|
||||
@@ -152,37 +157,37 @@ class TestQueryRunners:
|
||||
|
||||
|
||||
class TestGetRunnerCount:
|
||||
@patch("devx.ci.discover_runners.query_runners", return_value=5)
|
||||
@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.ci.discover_runners.query_runners", return_value=0)
|
||||
@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.ci.discover_runners.query_runners", return_value=0)
|
||||
@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.ci.discover_runners.query_runners", return_value=0)
|
||||
@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.ci.discover_runners.query_runners", return_value=0)
|
||||
@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.ci.discover_runners.query_runners", return_value=0)
|
||||
@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."""
|
||||
@@ -192,7 +197,7 @@ class TestGetRunnerCount:
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
|
||||
@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, [])
|
||||
@@ -200,28 +205,28 @@ class TestMain:
|
||||
assert "count=3" in result.output
|
||||
assert 'indices=["1", "2", "3"]' in result.output
|
||||
|
||||
@patch("devx.ci.discover_runners.get_runner_count", return_value=5)
|
||||
@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.ci.discover_runners.get_runner_count", return_value=4)
|
||||
@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.ci.discover_runners.get_runner_count", return_value=1)
|
||||
@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.ci.discover_runners.get_runner_count", return_value=3)
|
||||
@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))
|
||||
@@ -232,14 +237,14 @@ class TestMain:
|
||||
assert "runner-count=3" in content
|
||||
assert "runner-indices=" in content
|
||||
|
||||
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
|
||||
@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.ci.discover_runners.get_runner_count", return_value=2)
|
||||
@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()
|
||||
@@ -251,8 +256,8 @@ class TestMain:
|
||||
assert "myorg" in args
|
||||
assert "myrepo" in args
|
||||
|
||||
@patch("devx.ci.discover_runners.get_ci_token", side_effect=click.ClickException("no token"))
|
||||
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
|
||||
@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()
|
||||
@@ -261,3 +266,29 @@ class TestMain:
|
||||
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)
|
||||
|
||||
@@ -268,6 +268,18 @@ class TestCheckFile:
|
||||
errors = _check_file(f, tmp_path)
|
||||
assert len(errors) == 2
|
||||
|
||||
def test_check_file_os_error(self, tmp_path: Path, monkeypatch) -> None:
|
||||
"""OSError reading a file returns empty errors (not a crash)."""
|
||||
f = tmp_path / "playbook.yml"
|
||||
f.write_text("- name: ok\n set_fact:\n x: 1\n")
|
||||
|
||||
def _raise(*args, **kwargs):
|
||||
raise OSError("disk error")
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", _raise)
|
||||
errors = _check_file(f, tmp_path)
|
||||
assert errors == []
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_passes_when_clean(self, tmp_path: Path):
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Unit tests for devx.ci.wait_for_checks."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.wait_for_checks import (
|
||||
main,
|
||||
poll_until_complete,
|
||||
query_job_status,
|
||||
)
|
||||
|
||||
|
||||
def _mock_response(status_code: int = 200, json_data: object | None = None) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.status_code = status_code
|
||||
if json_data is None:
|
||||
m.json.side_effect = ValueError("no json")
|
||||
else:
|
||||
m.json.return_value = json_data
|
||||
return m
|
||||
|
||||
|
||||
class TestQueryJobStatus:
|
||||
@patch("devx.ci.wait_for_checks.requests.get")
|
||||
def test_returns_matching_jobs(self, mock_get: MagicMock) -> None:
|
||||
"""Jobs whose name starts with the prefix are returned."""
|
||||
mock_get.side_effect = [
|
||||
_mock_response(200, [{"id": 1}, {"id": 2}]),
|
||||
_mock_response(200, [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]),
|
||||
_mock_response(200, [{"name": "other-job", "status": "completed", "conclusion": "success"}]),
|
||||
]
|
||||
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "molecule-tests (1)"
|
||||
assert result[0]["status"] == "completed"
|
||||
assert result[0]["conclusion"] == "success"
|
||||
|
||||
@patch("devx.ci.wait_for_checks.requests.get")
|
||||
def test_no_matching_jobs(self, mock_get: MagicMock) -> None:
|
||||
"""When no job names match the prefix, returns empty list."""
|
||||
mock_get.side_effect = [
|
||||
_mock_response(200, [{"id": 1}]),
|
||||
_mock_response(200, [{"name": "other-job", "status": "completed", "conclusion": "success"}]),
|
||||
]
|
||||
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
|
||||
assert result == []
|
||||
|
||||
@patch("devx.ci.wait_for_checks.requests.get")
|
||||
def test_api_error_returns_empty(self, mock_get: MagicMock) -> None:
|
||||
"""Network errors on the runs endpoint return an empty list."""
|
||||
import requests
|
||||
|
||||
mock_get.side_effect = requests.ConnectionError("down")
|
||||
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
|
||||
assert result == []
|
||||
|
||||
@patch("devx.ci.wait_for_checks.requests.get")
|
||||
def test_non_200_returns_empty(self, mock_get: MagicMock) -> None:
|
||||
"""Non-200 on runs endpoint returns empty list."""
|
||||
mock_get.side_effect = [_mock_response(500, {"message": "err"})]
|
||||
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
|
||||
assert result == []
|
||||
|
||||
@patch("devx.ci.wait_for_checks.requests.get")
|
||||
def test_jobs_as_dict_with_jobs_key(self, mock_get: MagicMock) -> None:
|
||||
"""Jobs endpoint returning {'jobs': [...]} dict is handled."""
|
||||
mock_get.side_effect = [
|
||||
_mock_response(200, [{"id": 1}]),
|
||||
_mock_response(
|
||||
200, {"jobs": [{"name": "molecule-tests (1)", "status": "in_progress", "conclusion": None}]}
|
||||
),
|
||||
]
|
||||
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
|
||||
assert len(result) == 1
|
||||
assert result[0]["status"] == "in_progress"
|
||||
|
||||
@patch("devx.ci.wait_for_checks.requests.get")
|
||||
def test_runs_as_dict_with_runs_key(self, mock_get: MagicMock) -> None:
|
||||
"""Runs endpoint returning {'runs': [...]} dict is handled."""
|
||||
mock_get.side_effect = [
|
||||
_mock_response(200, {"runs": [{"id": 1}]}),
|
||||
_mock_response(200, [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]),
|
||||
]
|
||||
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
|
||||
assert len(result) == 1
|
||||
|
||||
@patch("devx.ci.wait_for_checks.requests.get")
|
||||
def test_jobs_endpoint_error_skips_run(self, mock_get: MagicMock) -> None:
|
||||
"""A failed jobs query for one run doesn't abort the whole call."""
|
||||
mock_get.side_effect = [
|
||||
_mock_response(200, [{"id": 1}, {"id": 2}]),
|
||||
_mock_response(500, {"message": "err"}),
|
||||
_mock_response(200, [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]),
|
||||
]
|
||||
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
|
||||
assert len(result) == 1
|
||||
|
||||
@patch("devx.ci.wait_for_checks.requests.get")
|
||||
def test_run_without_id_skipped(self, mock_get: MagicMock) -> None:
|
||||
"""Runs missing an 'id' field are skipped."""
|
||||
mock_get.side_effect = [
|
||||
_mock_response(200, [{"foo": "bar"}, {"id": 1}]),
|
||||
_mock_response(200, [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]),
|
||||
]
|
||||
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
|
||||
assert len(result) == 1
|
||||
|
||||
@patch("devx.ci.wait_for_checks.requests.get")
|
||||
def test_jobs_query_exception_skips_run(self, mock_get: MagicMock) -> None:
|
||||
"""A ConnectionError on the jobs endpoint for one run is skipped."""
|
||||
import requests
|
||||
|
||||
mock_get.side_effect = [
|
||||
_mock_response(200, [{"id": 1}, {"id": 2}]),
|
||||
requests.ConnectionError("down"),
|
||||
_mock_response(200, [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]),
|
||||
]
|
||||
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
|
||||
assert len(result) == 1
|
||||
|
||||
@patch("devx.ci.wait_for_checks.requests.get")
|
||||
def test_jobs_json_value_error_skips_run(self, mock_get: MagicMock) -> None:
|
||||
"""A ValueError (bad JSON) on the jobs endpoint is skipped."""
|
||||
mock_get.side_effect = [
|
||||
_mock_response(200, [{"id": 1}]),
|
||||
_mock_response(200), # json raises ValueError by default
|
||||
]
|
||||
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestPollUntilComplete:
|
||||
@patch("devx.ci.wait_for_checks.time.sleep")
|
||||
@patch("devx.ci.wait_for_checks.time.monotonic")
|
||||
@patch("devx.ci.wait_for_checks.query_job_status")
|
||||
def test_all_jobs_succeed(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
"""All jobs completed with success → returns 0."""
|
||||
mock_query.return_value = [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]
|
||||
mock_mono.side_effect = [0.0, 0.0]
|
||||
code = poll_until_complete("https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10)
|
||||
assert code == 0
|
||||
|
||||
@patch("devx.ci.wait_for_checks.time.sleep")
|
||||
@patch("devx.ci.wait_for_checks.time.monotonic")
|
||||
@patch("devx.ci.wait_for_checks.query_job_status")
|
||||
def test_job_fails(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
"""A job with non-success conclusion → returns 1."""
|
||||
mock_query.return_value = [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "failure"}]
|
||||
mock_mono.side_effect = [0.0, 0.0]
|
||||
code = poll_until_complete("https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10)
|
||||
assert code == 1
|
||||
|
||||
@patch("devx.ci.wait_for_checks.time.sleep")
|
||||
@patch("devx.ci.wait_for_checks.time.monotonic")
|
||||
@patch("devx.ci.wait_for_checks.query_job_status")
|
||||
def test_no_require_success(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
"""With require_success=False, a failed job returns 0."""
|
||||
mock_query.return_value = [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "failure"}]
|
||||
mock_mono.side_effect = [0.0, 0.0]
|
||||
code = poll_until_complete(
|
||||
"https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10, require_success=False
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
@patch("devx.ci.wait_for_checks.time.sleep")
|
||||
@patch("devx.ci.wait_for_checks.time.monotonic")
|
||||
@patch("devx.ci.wait_for_checks.query_job_status")
|
||||
def test_timeout(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
"""Jobs never complete → returns 2 after timeout."""
|
||||
mock_query.return_value = [{"name": "molecule-tests (1)", "status": "in_progress", "conclusion": None}]
|
||||
# monotonic calls: deadline=0, while-check=0 (enter), sleep-calc=0, while-check=200 (exit)
|
||||
mock_mono.side_effect = [0.0, 0.0, 0.0, 200.0]
|
||||
code = poll_until_complete("https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10)
|
||||
assert code == 2
|
||||
|
||||
@patch("devx.ci.wait_for_checks.time.sleep")
|
||||
@patch("devx.ci.wait_for_checks.time.monotonic")
|
||||
@patch("devx.ci.wait_for_checks.query_job_status")
|
||||
def test_no_jobs_found_timeout(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
"""No matching jobs at all → returns 3."""
|
||||
mock_query.return_value = []
|
||||
# monotonic calls: deadline=0, while-check=0 (enter), sleep-calc=0, while-check=200 (exit)
|
||||
mock_mono.side_effect = [0.0, 0.0, 0.0, 200.0]
|
||||
code = poll_until_complete("https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10)
|
||||
assert code == 3
|
||||
|
||||
@patch("devx.ci.wait_for_checks.time.sleep")
|
||||
@patch("devx.ci.wait_for_checks.time.monotonic")
|
||||
@patch("devx.ci.wait_for_checks.query_job_status")
|
||||
def test_in_progress_then_success(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
"""First poll in_progress, second poll success → returns 0."""
|
||||
mock_query.side_effect = [
|
||||
[{"name": "molecule-tests (1)", "status": "in_progress", "conclusion": None}],
|
||||
[{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}],
|
||||
]
|
||||
# monotonic: deadline=0, while=0 (enter), sleep-calc=0, while=5 (enter), success→return
|
||||
mock_mono.side_effect = [0.0, 0.0, 0.0, 5.0]
|
||||
code = poll_until_complete("https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10)
|
||||
assert code == 0
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
|
||||
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=0)
|
||||
def test_success_exit_code(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
|
||||
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=1)
|
||||
def test_failure_exit_code(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
|
||||
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=2)
|
||||
def test_timeout_exit_code(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r"])
|
||||
assert result.exit_code == 2
|
||||
|
||||
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
|
||||
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=3)
|
||||
def test_api_error_exit_code(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r"])
|
||||
assert result.exit_code == 3
|
||||
|
||||
def test_missing_job_name(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "o/r"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_missing_repo(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--job-name", "molecule-tests"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
|
||||
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=0)
|
||||
def test_repo_from_env(self, mock_poll: MagicMock, mock_token: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITHUB_REPOSITORY", "oblachno-oss/grm")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--job-name", "molecule-tests"])
|
||||
assert result.exit_code == 0
|
||||
args, kwargs = mock_poll.call_args
|
||||
assert args[2] == "oblachno-oss/grm"
|
||||
|
||||
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
|
||||
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=0)
|
||||
def test_invalid_timeout(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r", "--timeout", "0"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
|
||||
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=0)
|
||||
def test_invalid_interval(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r", "--poll-interval", "0"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@patch("devx.ci.wait_for_checks.get_ci_token", side_effect=__import__("click").ClickException("no token"))
|
||||
def test_no_token_exit_3(self, mock_token: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r"])
|
||||
assert result.exit_code == 3
|
||||
Reference in New Issue
Block a user