import os import sys import shutil import tempfile import unittest from textwrap import dedent PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) if PROJECT_ROOT not in sys.path: sys.path.insert(0, PROJECT_ROOT) from module_utils.role_dependency_resolver import RoleDependencyResolver # noqa: E402 def write(path: str, content: str): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: f.write(dedent(content).lstrip()) def make_role(roles_dir: str, name: str): path = os.path.join(roles_dir, name) os.makedirs(path, exist_ok=True) os.makedirs(os.path.join(path, "tasks"), exist_ok=True) os.makedirs(os.path.join(path, "meta"), exist_ok=True) return path class TestRoleDependencyResolver(unittest.TestCase): def setUp(self): self.roles_dir = tempfile.mkdtemp(prefix="roles_") def tearDown(self): shutil.rmtree(self.roles_dir, ignore_errors=True) # ----------------------------- TESTS ----------------------------- def test_include_and_import_literal(self): """ A/tasks/main.yml: - include_role: { name: B } - import_role: { name: C } Expect: deps = {B, C} """ make_role(self.roles_dir, "A") make_role(self.roles_dir, "B") make_role(self.roles_dir, "C") write( os.path.join(self.roles_dir, "A", "tasks", "main.yml"), """ - name: include B include_role: name: B - name: import C import_role: name: C """ ) r = RoleDependencyResolver(self.roles_dir) deps = r.get_role_dependencies("A") self.assertEqual(deps, {"B", "C"}) def test_loop_with_string_items_and_dict_items(self): """ A/tasks/main.yml uses loop with strings and dicts. Expect: {D, E, F, G} """ make_role(self.roles_dir, "A") for rn in ["D", "E", "F", "G"]: make_role(self.roles_dir, rn) write( os.path.join(self.roles_dir, "A", "tasks", "main.yml"), """ - name: loop over strings → D, E include_role: name: "{{ item }}" loop: - D - E - name: loop over dicts → F, G import_role: name: "{{ item.role }}" with_items: - { role: "F" } - { role: "G" } """ ) r = RoleDependencyResolver(self.roles_dir) deps = r.get_role_dependencies("A") self.assertEqual(deps, {"D", "E", "F", "G"}) def test_jinja_mixed_name_glob_matching(self): """ include_role: name: "prefix-{{ item }}-suffix" loop: [x, y] Existierende Rollen: prefix-x-suffix, prefix-y-suffix, prefix-z-suffix Erwartung: - KEINE Roh-Items ('x', 'y') als Rollen - Glob-Matching liefert die drei konkreten Rollen """ make_role(self.roles_dir, "A") for rn in ["prefix-x-suffix", "prefix-y-suffix", "prefix-z-suffix"]: make_role(self.roles_dir, rn) write( os.path.join(self.roles_dir, "A", "tasks", "main.yml"), """ - name: jinja-mixed glob include_role: name: "prefix-{{ item }}-suffix" loop: - x - y """ ) r = RoleDependencyResolver(self.roles_dir) deps = r.get_role_dependencies("A") # keine Roh-Loop-Items self.assertNotIn("x", deps) self.assertNotIn("y", deps) # erwartete Rollen aus dem Glob-Matching self.assertEqual( deps, {"prefix-x-suffix", "prefix-y-suffix", "prefix-z-suffix"}, ) def test_pure_jinja_ignored_without_loop(self): """ name: "{{ something }}" with no loop should be ignored. """ make_role(self.roles_dir, "A") for rn in ["X", "Y"]: make_role(self.roles_dir, rn) write( os.path.join(self.roles_dir, "A", "tasks", "main.yml"), """ - name: pure var ignored include_role: name: "{{ something }}" """ ) r = RoleDependencyResolver(self.roles_dir) deps = r.get_role_dependencies("A") self.assertEqual(deps, set()) def test_meta_dependencies_strings_and_dicts(self): """ meta/main.yml: dependencies: - H - { role: I } Expect: {H, I} """ make_role(self.roles_dir, "A") make_role(self.roles_dir, "H") make_role(self.roles_dir, "I") write( os.path.join(self.roles_dir, "A", "meta", "main.yml"), """ --- dependencies: - H - { role: I } """ ) r = RoleDependencyResolver(self.roles_dir) deps = r.get_role_dependencies("A") self.assertEqual(deps, {"H", "I"}) def test_run_after_extraction_and_toggle(self): """ galaxy_info.run_after is only included when resolve_run_after=True """ make_role(self.roles_dir, "A") make_role(self.roles_dir, "J") make_role(self.roles_dir, "K") write( os.path.join(self.roles_dir, "A", "meta", "main.yml"), """ --- galaxy_info: run_after: - J - K dependencies: [] """ ) r = RoleDependencyResolver(self.roles_dir) # Direkter Helper ra = r._extract_meta_run_after(os.path.join(self.roles_dir, "A")) self.assertEqual(ra, {"J", "K"}) # Transitiv – off by default visited_off = r.resolve_transitively(["A"], resolve_run_after=False) self.assertNotIn("J", visited_off) self.assertNotIn("K", visited_off) # Transitiv – enabled visited_on = r.resolve_transitively(["A"], resolve_run_after=True) self.assertTrue({"A", "J", "K"}.issubset(visited_on)) def test_cycle_and_max_depth(self): """ A → include B B → import A - Ensure cycle-safe traversal. - max_depth=0 → only start - max_depth=1 → start + direct deps """ make_role(self.roles_dir, "A") make_role(self.roles_dir, "B") write( os.path.join(self.roles_dir, "A", "tasks", "main.yml"), """ - include_role: name: B """ ) write( os.path.join(self.roles_dir, "B", "tasks", "main.yml"), """ - import_role: name: A """ ) r = RoleDependencyResolver(self.roles_dir) visited = r.resolve_transitively(["A"]) self.assertTrue({"A", "B"}.issubset(visited)) only_start = r.resolve_transitively(["A"], max_depth=0) self.assertEqual(only_start, {"A"}) depth_one = r.resolve_transitively(["A"], max_depth=1) self.assertEqual(depth_one, {"A", "B"}) def test_tolerant_scan_fallback_on_invalid_yaml(self): """ Force yaml.safe_load_all to fail and ensure tolerant scan picks up: - include_role literal name - loop list items """ make_role(self.roles_dir, "A") for rn in ["R1", "R2", "R3"]: make_role(self.roles_dir, rn) # Invalid YAML (e.g., stray colon) to trigger exception write( os.path.join(self.roles_dir, "A", "tasks", "broken.yml"), """ include_role: name: R1 :: this line breaks YAML :: - include_role: name: "{{ item }}" loop: - R2 - R3 """ ) r = RoleDependencyResolver(self.roles_dir) inc, imp = r._scan_tasks(os.path.join(self.roles_dir, "A")) self.assertTrue({"R1", "R2", "R3"}.issubset(inc)) self.assertEqual(imp, set()) def test_resolve_transitively_combined_sources(self): """ Combined test: include/import + dependencies (+ optional run_after). """ for rn in ["ROOT", "C1", "C2", "D1", "D2", "RA1"]: make_role(self.roles_dir, rn) write( os.path.join(self.roles_dir, "ROOT", "tasks", "main.yml"), """ - include_role: { name: C1 } - import_role: { name: C2 } """ ) write( os.path.join(self.roles_dir, "ROOT", "meta", "main.yml"), """ --- dependencies: - D1 - { role: D2 } galaxy_info: run_after: - RA1 """ ) r = RoleDependencyResolver(self.roles_dir) # Ohne run_after visited = r.resolve_transitively(["ROOT"], resolve_run_after=False) for expected in ["ROOT", "C1", "C2", "D1", "D2"]: self.assertIn(expected, visited) self.assertNotIn("RA1", visited) # Mit run_after visited_ra = r.resolve_transitively(["ROOT"], resolve_run_after=True) self.assertIn("RA1", visited_ra) if __name__ == "__main__": unittest.main()