"""Unit tests for scripts/ci/distribute_molecule.py.""" from pathlib import Path from unittest.mock import patch import click import pytest from click.testing import CliRunner from devx.molecule.distribute_molecule import ( DEFAULT_ROLES_ROOT, MOLECULE_ROOT, PLATFORMS, MultiRoleTestPair, TestPair, _lpt_distribute, _scenario_weight, build_multi_role_pairs, build_pairs, cli, discover_multi_role_scenarios, discover_scenarios, distribute, distribute_multi_role, multi_role_pairs_for_runner, pairs_for_runner, ) class TestDiscoverScenarios: def test_discovers_scenarios(self, tmp_path: Path) -> None: root = tmp_path / "molecule" (root / "default").mkdir(parents=True) (root / "binary").mkdir(parents=True) (root / "common").mkdir(parents=True) (root / "_shared").mkdir(parents=True) result = discover_scenarios(root) assert result == ["binary", "default"] def test_raises_when_dir_missing(self, tmp_path: Path) -> None: with pytest.raises(click.ClickException) as exc: discover_scenarios(tmp_path / "nonexistent") assert "not found" in str(exc.value) def test_default_root_constant(self) -> None: assert Path("ansible/roles/gitea-runner/molecule") == MOLECULE_ROOT class TestPairEncoding: def test_encode_roundtrip(self) -> None: pair = TestPair("default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""}) encoded = pair.encode() assert encoded == "default|ubuntu-2204|ubuntu:22.04|" decoded = TestPair.decode(encoded) assert decoded.scenario == "default" assert decoded.platform["name"] == "ubuntu-2204" assert decoded.platform["image"] == "ubuntu:22.04" assert decoded.platform["command"] == "" def test_encode_with_command(self) -> None: pair = TestPair( "default", {"name": "archlinux", "image": "archlinux:latest", "command": "/usr/lib/systemd/systemd"}, ) encoded = pair.encode() assert encoded == "default|archlinux|archlinux:latest|/usr/lib/systemd/systemd" decoded = TestPair.decode(encoded) assert decoded.platform["command"] == "/usr/lib/systemd/systemd" class TestBuildPairs: def test_cross_product(self) -> None: scenarios = ["a", "b"] platforms = [ {"name": "p1", "image": "img1", "command": ""}, {"name": "p2", "image": "img2", "command": ""}, ] pairs = build_pairs(scenarios, platforms) assert len(pairs) == 4 assert pairs[0].scenario == "a" assert pairs[0].platform["name"] == "p1" assert pairs[1].scenario == "a" assert pairs[1].platform["name"] == "p2" assert pairs[2].scenario == "b" assert pairs[2].platform["name"] == "p1" assert pairs[3].scenario == "b" assert pairs[3].platform["name"] == "p2" def test_default_platforms(self) -> None: pairs = build_pairs(["default"]) assert len(pairs) == len(PLATFORMS) assert all(p.scenario == "default" for p in pairs) class TestDistribute: def test_even_split(self) -> None: pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)] groups = distribute(pairs, 3) assert len(groups) == 3 assert len(groups[0]) == 2 assert len(groups[1]) == 2 assert len(groups[2]) == 2 def test_uneven_split(self) -> None: pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(5)] groups = distribute(pairs, 3) assert len(groups[0]) == 2 assert len(groups[1]) == 2 assert len(groups[2]) == 1 def test_more_runners_than_pairs(self) -> None: pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})] groups = distribute(pairs, 5) assert len(groups) == 5 assert len(groups[0]) == 1 assert all(len(g) == 0 for g in groups[1:]) def test_empty_pairs(self) -> None: groups = distribute([], 3) assert groups == [[], [], []] class TestPairsForRunner: def test_returns_correct_subset(self) -> None: pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)] assert len(pairs_for_runner(pairs, 0, 3)) == 2 assert len(pairs_for_runner(pairs, 1, 3)) == 2 assert len(pairs_for_runner(pairs, 2, 3)) == 2 def test_out_of_range_raises(self) -> None: pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})] with pytest.raises(click.ClickException) as exc: pairs_for_runner(pairs, 5, 3) assert "out of range" in str(exc.value) def test_negative_index_raises(self) -> None: pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})] with pytest.raises(click.ClickException) as exc: pairs_for_runner(pairs, -1, 3) assert "out of range" in str(exc.value) class TestCli: def test_list_flag(self, tmp_path: Path) -> None: from click.testing import CliRunner from devx.molecule.distribute_molecule import cli root = tmp_path / "molecule" (root / "alpha").mkdir(parents=True) (root / "beta").mkdir(parents=True) with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root): runner = CliRunner() result = runner.invoke(cli, ["--list"]) assert result.exit_code == 0 assert "alpha" in result.output assert "beta" in result.output def test_list_platforms_flag(self) -> None: from click.testing import CliRunner from devx.molecule.distribute_molecule import cli with patch("devx.molecule.distribute_molecule.discover_scenarios", return_value=["dummy"]): runner = CliRunner() result = runner.invoke(cli, ["--list-platforms"]) assert result.exit_code == 0 assert "ubuntu-2604" in result.output def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None: from click.testing import CliRunner from devx.molecule.distribute_molecule import cli root = tmp_path / "molecule" for s in ["a", "b", "c"]: (root / s).mkdir(parents=True) with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root): runner = CliRunner() result = runner.invoke(cli, ["--max-runners", "3"]) assert result.exit_code == 0 assert "Runner 0:" in result.output assert "Runner 1:" in result.output assert "Runner 2:" in result.output def test_runner_index_prints_assigned(self, tmp_path: Path) -> None: from click.testing import CliRunner from devx.molecule.distribute_molecule import cli root = tmp_path / "molecule" (root / "alpha").mkdir(parents=True) with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root): runner = CliRunner() # 1-based index: "1" maps to internal 0 result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3"]) assert result.exit_code == 0 # Output should contain encoded pairs with platform info assert "alpha|" in result.output assert "ubuntu-2604" in result.output class TestGithubEnv: def test_writes_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: gh_file = tmp_path / "env.txt" monkeypatch.setenv("GITHUB_ENV", str(gh_file)) root = tmp_path / "molecule" scenario = root / "alpha" scenario.mkdir(parents=True) (scenario / "molecule.yml").write_text("name: alpha\n") with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root): runner = CliRunner() result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"]) assert result.exit_code == 0 content = gh_file.read_text() assert "TEST_PAIRS=" in content assert "SKIP=false" in content def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: gh_file = tmp_path / "env.txt" monkeypatch.setenv("GITHUB_ENV", str(gh_file)) root = tmp_path / "molecule" scenario = root / "alpha" scenario.mkdir(parents=True) (scenario / "molecule.yml").write_text("name: alpha\n") with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root): runner = CliRunner() result = runner.invoke( cli, ["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"] ) assert result.exit_code == 0 content = gh_file.read_text() assert "TEST_PAIRS=\n" in content assert "SKIP=true" in content def test_no_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("GITHUB_ENV", raising=False) root = tmp_path / "molecule" scenario = root / "alpha" scenario.mkdir(parents=True) (scenario / "molecule.yml").write_text("name: alpha\n") with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root): runner = CliRunner() result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"]) assert result.exit_code != 0 class TestRunnerIndexValidation: def test_runner_index_zero_raises(self) -> None: """Runner index < 1 should raise.""" with patch("devx.molecule.distribute_molecule.discover_scenarios", return_value=["dummy"]): runner = CliRunner() result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"]) assert result.exit_code != 0 assert "out of range" in result.output def test_main_module_block() -> None: import devx.molecule.distribute_molecule as dm with open(dm.__file__) as f: source = f.read() source = source.replace('if __name__ == "__main__":\n cli()\n', "") namespace = dict(dm.__dict__) exec(compile(source, dm.__file__, "exec"), namespace) assert callable(namespace["cli"]) class TestDiscoverMultiRole: def test_discovers_role_scenario_pairs(self, tmp_path: Path) -> None: roles = tmp_path / "roles" for scenario in ["default", "binary"]: (roles / "gitea-runner" / "molecule" / scenario).mkdir(parents=True) (roles / "gitea-runner" / "molecule" / "common").mkdir(parents=True) (roles / "gitea-runner" / "molecule" / "_shared").mkdir(parents=True) (roles / "docker-base" / "molecule" / "default").mkdir(parents=True) (roles / "no-molecule").mkdir(parents=True) result = discover_multi_role_scenarios(roles) assert ("docker-base", "default") in result assert ("gitea-runner", "default") in result assert ("gitea-runner", "binary") in result assert ("gitea-runner", "common") not in result assert ("gitea-runner", "_shared") not in result assert len(result) == 3 def test_raises_when_dir_missing(self, tmp_path: Path) -> None: with pytest.raises(click.ClickException) as exc: discover_multi_role_scenarios(tmp_path / "nonexistent") assert "not found" in str(exc.value) def test_default_roles_root_raises_when_missing(self, monkeypatch: pytest.MonkeyPatch) -> None: """Calling with no args uses DEFAULT_ROLES_ROOT which doesn't exist in tests.""" with pytest.raises(click.ClickException): discover_multi_role_scenarios() def test_default_roles_root_constant(self) -> None: assert Path("ansible/roles") == DEFAULT_ROLES_ROOT class TestMultiRoleTestPair: def test_encode_roundtrip(self) -> None: pair = MultiRoleTestPair( "docker-base", "default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""} ) encoded = pair.encode() assert encoded == "docker-base|default|ubuntu-2204|ubuntu:22.04|" decoded = MultiRoleTestPair.decode(encoded) assert decoded.role == "docker-base" assert decoded.scenario == "default" assert decoded.platform["name"] == "ubuntu-2204" class TestBuildMultiRolePairs: def test_cross_product(self) -> None: role_scenarios = [("role-a", "default"), ("role-b", "binary")] platforms = [{"name": "p1", "image": "i1", "command": ""}] pairs = build_multi_role_pairs(role_scenarios, platforms) assert len(pairs) == 2 assert pairs[0].role == "role-a" assert pairs[1].role == "role-b" def test_default_platforms(self) -> None: pairs = build_multi_role_pairs([("r", "s")]) assert len(pairs) == len(PLATFORMS) class TestDistributeMultiRole: def test_even_split(self) -> None: pairs = [MultiRoleTestPair(f"r{i}", "s", {"name": "p", "image": "i", "command": ""}) for i in range(6)] groups = distribute_multi_role(pairs, 3) assert all(len(g) == 2 for g in groups) def test_out_of_range_raises(self) -> None: pairs = [MultiRoleTestPair("r", "s", {"name": "p", "image": "i", "command": ""})] with pytest.raises(click.ClickException): multi_role_pairs_for_runner(pairs, 5, 3) class TestCliMultiRole: def test_roles_root_list(self, tmp_path: Path) -> None: roles = tmp_path / "roles" (roles / "role-a" / "molecule" / "default").mkdir(parents=True) (roles / "role-b" / "molecule" / "binary").mkdir(parents=True) runner = CliRunner() result = runner.invoke(cli, ["--roles-root", str(roles), "--list"]) assert result.exit_code == 0 assert "role-a|default" in result.output assert "role-b|binary" in result.output def test_roles_root_runner_index(self, tmp_path: Path) -> None: roles = tmp_path / "roles" (roles / "role-a" / "molecule" / "default").mkdir(parents=True) runner = CliRunner() result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "1", "--max-runners", "3"]) assert result.exit_code == 0 assert "role-a|default|" in result.output def test_roles_root_github_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: gh_file = tmp_path / "env.txt" monkeypatch.setenv("GITHUB_ENV", str(gh_file)) roles = tmp_path / "roles" (roles / "role-a" / "molecule" / "default").mkdir(parents=True) runner = CliRunner() result = runner.invoke( cli, ["--roles-root", str(roles), "--runner-index", "1", "--max-runners", "3", "--github-env"], ) assert result.exit_code == 0 content = gh_file.read_text() assert "TEST_PAIRS=" in content assert "SKIP=false" in content def test_roles_root_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: gh_file = tmp_path / "env.txt" monkeypatch.setenv("GITHUB_ENV", str(gh_file)) roles = tmp_path / "roles" (roles / "role-a" / "molecule" / "default").mkdir(parents=True) runner = CliRunner() result = runner.invoke( cli, [ "--roles-root", str(roles), "--runner-index", "5", "--max-runners", "2", "--github-env", "--skip-if-excess", ], ) assert result.exit_code == 0 content = gh_file.read_text() assert "SKIP=true" in content def test_molecule_root_option(self, tmp_path: Path) -> None: root = tmp_path / "custom-molecule" (root / "alpha").mkdir(parents=True) runner = CliRunner() result = runner.invoke(cli, ["--molecule-root", str(root), "--list"]) assert result.exit_code == 0 assert "alpha" in result.output def test_roles_root_list_platforms(self, tmp_path: Path) -> None: """--roles-root --list-platforms prints platforms.""" roles = tmp_path / "roles" (roles / "role-a" / "molecule" / "default").mkdir(parents=True) runner = CliRunner() result = runner.invoke(cli, ["--roles-root", str(roles), "--list-platforms"]) assert result.exit_code == 0 assert "ubuntu-2604" in result.output def test_roles_root_no_runner_index_prints_groups(self, tmp_path: Path) -> None: """--roles-root without --runner-index prints all groups.""" roles = tmp_path / "roles" (roles / "role-a" / "molecule" / "default").mkdir(parents=True) (roles / "role-b" / "molecule" / "binary").mkdir(parents=True) runner = CliRunner() result = runner.invoke(cli, ["--roles-root", str(roles), "--max-runners", "2"]) assert result.exit_code == 0 assert "Runner 0:" in result.output assert "Runner 1:" in result.output def test_platforms_file_overrides_default(self, tmp_path: Path) -> None: """--platforms-file loads custom platforms from JSON.""" import json from click.testing import CliRunner from devx.molecule.distribute_molecule import cli roles = tmp_path / "roles" (roles / "role-a" / "molecule" / "default").mkdir(parents=True) platforms_file = tmp_path / "platforms.json" custom = [{"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"}] platforms_file.write_text(json.dumps(custom)) runner = CliRunner() result = runner.invoke( cli, ["--roles-root", str(roles), "--platforms-file", str(platforms_file), "--list-platforms"] ) assert result.exit_code == 0 assert "custom-os" in result.output assert "custom:latest" in result.output def test_roles_root_skips_non_dir_role(self, tmp_path: Path) -> None: """Non-directory entries in roles root are skipped.""" roles = tmp_path / "roles" roles.mkdir(parents=True) (roles / "README.md").write_text("not a role") (roles / "role-a" / "molecule" / "default").mkdir(parents=True) result = discover_multi_role_scenarios(roles) assert ("role-a", "default") in result assert len(result) == 1 def test_roles_root_skips_non_dir_scenario(self, tmp_path: Path) -> None: """Non-directory entries in molecule dir are skipped.""" roles = tmp_path / "roles" (roles / "role-a" / "molecule").mkdir(parents=True) (roles / "role-a" / "molecule" / "default").mkdir(parents=True) (roles / "role-a" / "molecule" / "file.txt").write_text("not a scenario") result = discover_multi_role_scenarios(roles) assert ("role-a", "default") in result assert len(result) == 1 def test_roles_root_skips_role_without_molecule(self, tmp_path: Path) -> None: """Roles without a molecule/ directory are skipped.""" roles = tmp_path / "roles" (roles / "role-a" / "molecule" / "default").mkdir(parents=True) (roles / "no-molecule").mkdir(parents=True) result = discover_multi_role_scenarios(roles) assert ("role-a", "default") in result assert len(result) == 1 def test_roles_root_runner_index_zero_raises(self, tmp_path: Path) -> None: """--roles-root --runner-index 0 should raise.""" roles = tmp_path / "roles" (roles / "role-a" / "molecule" / "default").mkdir(parents=True) runner = CliRunner() result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"]) assert result.exit_code != 0 assert "out of range" in result.output class TestScenarioWeight: def test_known_heavy_scenario(self) -> None: assert _scenario_weight("nextcloud") == 10 assert _scenario_weight("gitea") == 8 def test_known_light_scenario(self) -> None: assert _scenario_weight("binary") == 2 def test_default_weight(self) -> None: assert _scenario_weight("unknown-scenario") == 3 def test_case_insensitive(self) -> None: assert _scenario_weight("NextCloud") == 10 assert _scenario_weight("GITEA") == 8 def test_substring_match(self) -> None: assert _scenario_weight("nextcloud-with-redis") == 10 assert _scenario_weight("custom-gitea-setup") == 8 class TestLptDistribute: def test_equal_weights_produce_even_split(self) -> None: items = list(range(6)) weights = [3, 3, 3, 3, 3, 3] groups = _lpt_distribute(items, weights, 3) assert all(len(g) == 2 for g in groups) def test_heavy_items_on_different_runners(self) -> None: """Two heavy items should go to different runners.""" items = ["heavy-a", "heavy-b", "light-1", "light-2"] weights = [10, 10, 1, 1] groups = _lpt_distribute(items, weights, 2) # Heavy items should be on different runners flat = [item for group in groups for item in group] assert "heavy-a" in flat assert "heavy-b" in flat runner_a = next(i for i, g in enumerate(groups) if "heavy-a" in g) runner_b = next(i for i, g in enumerate(groups) if "heavy-b" in g) assert runner_a != runner_b def test_load_balance_with_varying_weights(self) -> None: """LPT should produce better load balance than round-robin.""" items = list(range(7)) # Simulate infra-like weights: 2 heavy, 2 medium, 3 light weights = [10, 10, 7, 7, 3, 3, 3] groups = _lpt_distribute(items, weights, 3) loads = [sum(weights[i] for i in g) for g in groups] # LPT should produce loads close to total/3 = 43/3 ≈ 14.3 # Round-robin would produce: 10+7+3=20, 10+7+3=20, 3=3 (terrible) assert max(loads) - min(loads) <= 10 # Reasonably balanced def test_more_runners_than_items(self) -> None: items = ["a"] weights = [5] groups = _lpt_distribute(items, weights, 5) assert len(groups) == 5 assert len(groups[0]) == 1 assert all(len(g) == 0 for g in groups[1:]) def test_empty_items(self) -> None: groups = _lpt_distribute([], [], 3) assert groups == [[], [], []] def test_preserves_all_items(self) -> None: items = ["a", "b", "c", "d", "e"] weights = [5, 3, 8, 1, 2] groups = _lpt_distribute(items, weights, 3) flat = sorted(item for group in groups for item in group) assert flat == sorted(items) class TestDistributeLpt: def test_nextcloud_on_separate_runners(self) -> None: """Two nextcloud scenarios should go to different runners.""" pairs = [ TestPair("nextcloud", {"name": "p", "image": "i", "command": ""}), TestPair("nextcloud-backup", {"name": "p", "image": "i", "command": ""}), TestPair("binary", {"name": "p", "image": "i", "command": ""}), TestPair("default", {"name": "p", "image": "i", "command": ""}), ] groups = distribute(pairs, 2) # Both nextcloud scenarios (weight 10) should be on different runners runner_0 = [p.scenario for p in groups[0]] runner_1 = [p.scenario for p in groups[1]] # nextcloud and nextcloud-backup should NOT be on the same runner assert not ("nextcloud" in runner_0 and "nextcloud-backup" in runner_0) assert not ("nextcloud" in runner_1 and "nextcloud-backup" in runner_1)