"""Tests for local/scripts/migrate-kf6-seds-to-patches.sh. The migration script is bash; these tests validate the candidate discovery logic in a language with proper unit test infrastructure. The script itself is exercised manually with --dry-run on the live tree. """ import os import re import subprocess import tempfile import textwrap import unittest from pathlib import Path SCRIPT = Path(__file__).resolve().parent.parent / "migrate-kf6-seds-to-patches.sh" def _make_recipe( root: Path, category: str, name: str, *, has_sed: bool = True, has_tar: bool = True, ) -> Path: """Create a recipe.toml in the synthetic tree under root/local/recipes//.""" d = root / "local" / "recipes" / category / name d.mkdir(parents=True, exist_ok=True) body = ["[source]"] if has_tar: body += [ 'tar = "https://example.com/foo.tar.xz"', 'blake3 = "deadbeef"', ] body += ["", "[build]"] if has_sed: body += [ 'script = """', 'sed -i \'s/foo/bar/\' CMakeLists.txt', "make install", '"""', ] else: body += ['script = "cmake -B build"', ""] (d / "recipe.toml").write_text("\n".join(body) + "\n") return d def _run_dry_run(root: Path, extra: list[str] | None = None) -> subprocess.CompletedProcess: if extra is None: extra = [] env = os.environ.copy() env["MIGRATION_LOG_DIR"] = str(root / "logs") env["REDBEAR_MIGRATE_RECIPES_DIR"] = str(root / "local" / "recipes") env["REDBEAR_MIGRATE_PATCHES_DIR"] = str(root / "local" / "patches") # The script exits 1 when no candidates are found (legitimate # "nothing to migrate" signal). Don't raise — let the test # inspect stdout/stderr to assert on the outcome. return subprocess.run( [str(SCRIPT), "--dry-run", *extra], cwd=root, env=env, capture_output=True, text=True, timeout=30, check=False, ) class TestCandidateDiscovery(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name) def tearDown(self): self.tmp.cleanup() def test_discovers_sed_tar_recipe(self): _make_recipe(self.root, "kde", "kf6-foo") result = _run_dry_run(self.root) self.assertIn("kf6-foo", result.stdout) self.assertIn("Found 1 candidate", result.stdout) def test_skips_recipe_without_sed(self): _make_recipe(self.root, "kde", "kf6-clean", has_sed=False, has_tar=True) result = _run_dry_run(self.root) # The script exits 1 with a "no candidates" message to stderr. self.assertEqual(result.returncode, 1) self.assertIn("No sed-bearing tar-sourced recipes found", result.stderr) def test_skips_recipe_with_git_source(self): _make_recipe(self.root, "kde", "kf6-git", has_sed=True, has_tar=False) recipe = self.root / "local" / "recipes" / "kde" / "kf6-git" / "recipe.toml" text = recipe.read_text() text = text.replace( 'tar = "https://example.com/foo.tar.xz"', 'git = "https://example.com/foo.git"', ) text = text.replace('blake3 = "deadbeef"', 'rev = "main"') recipe.write_text(text) result = _run_dry_run(self.root) self.assertEqual(result.returncode, 1) self.assertIn("No sed-bearing tar-sourced recipes found", result.stderr) def test_limit_caps_results(self): for i in range(5): _make_recipe(self.root, "kde", f"kf6-r{i}") result = _run_dry_run(self.root, ["--limit=2"]) self.assertIn("Found 2 candidate", result.stdout) self.assertNotIn("kf6-r2", result.stdout) self.assertNotIn("kf6-r3", result.stdout) def test_recipe_filter_picks_specific_name(self): _make_recipe(self.root, "kde", "kf6-a") _make_recipe(self.root, "kde", "kf6-b") result = _run_dry_run(self.root, ["--recipe=kf6-b"]) self.assertIn("Found 1 candidate", result.stdout) self.assertIn("kf6-b", result.stdout) self.assertNotIn("kf6-a", result.stdout) def test_skips_existing_patch(self): _make_recipe(self.root, "kde", "kf6-existing") patch_dir = self.root / "local" / "patches" / "kf6-existing" patch_dir.mkdir(parents=True) (patch_dir / "01-initial-migration.patch").write_text("# existing") # We can't easily exercise the SKIP path without network; # the dry-run mode short-circuits before the SKIP check. # Validate the script source has the skip branch instead. script_text = SCRIPT.read_text() self.assertIn('if [ -e "$patch_file" ]', script_text) self.assertIn("SKIP — patch already exists", script_text) def test_help_output_describes_script(self): result = subprocess.run( [str(SCRIPT), "--help"], capture_output=True, text=True, timeout=5, ) self.assertEqual(result.returncode, 0) self.assertIn("C-7 KF6 sed migration", result.stdout) self.assertIn("--dry-run", result.stdout) self.assertIn("--recipe=", result.stdout) self.assertIn("--limit=", result.stdout) class TestScriptStructure(unittest.TestCase): def test_uses_repo_cook_bare_names(self): # The original v1 of this script called `repo cook # ` with a path, which is wrong. The v2 must # use bare names. This regression test catches the # "use paths instead of names" mistake. text = SCRIPT.read_text() self.assertIn('release/repo cook "$name"', text) self.assertIn('release/repo fetch "$name"', text) self.assertNotIn('repo cook "$recipe_dir"', text) self.assertNotIn('repo fetch "$recipe_dir"', text) def test_uses_release_repo_binary(self): text = SCRIPT.read_text() self.assertIn("./target/release/repo", text) def test_creates_patches_dir(self): text = SCRIPT.read_text() self.assertIn("mkdir -p \"$patch_dir\"", text) def test_diff_includes_target_exclude(self): text = SCRIPT.read_text() self.assertIn("--exclude='.git'", text) self.assertIn("--exclude='target'", text) def test_unfetch_after_capture(self): # After capturing the diff, the script should uncook # (unfetch) so the source is clean for the next run. text = SCRIPT.read_text() self.assertIn('release/repo unfetch "$name"', text) def test_idempotent_skip(self): # If a patch already exists, the script reports SKIP. text = SCRIPT.read_text() self.assertIn("SKIP — patch already exists", text) def test_sets_local_unfetch_env_var(self): # C-7 migration requires a truly pristine source tree. # The cookbook's default policy is to never clobber a # local-overlay source (kf6-*, qt* all live under # local/recipes/). The script MUST set # REDBEAR_ALLOW_LOCAL_UNFETCH=1 to bypass that policy, # otherwise the snapshot is the post-cook state and the # diff comes back empty (silent failure). text = SCRIPT.read_text() self.assertIn("REDBEAR_ALLOW_LOCAL_UNFETCH=1", text) def test_unfetches_before_fetching(self): # Cookbook `fetch` re-uses existing source/ if present. # Migration must explicitly unfetch first to get the # truly pristine state. Regression: a v3 that just # calls `fetch` will silently fail on already-cooked # recipes. text = SCRIPT.read_text() unfetch_pos = text.find('release/repo unfetch "$name"') fetch_pos = text.find('release/repo fetch "$name"') self.assertGreater(unfetch_pos, 0, "unfetch not found") self.assertGreater(fetch_pos, 0, "fetch not found") self.assertLess(unfetch_pos, fetch_pos, "unfetch must come before fetch") def test_cook_has_timeout(self): # Some upstream KF6 recipes (kf6-kauth, kf6-kconfig, # kf6-kwidgetsaddons) use autotools and the autoreconf # step can take 5+ minutes. Without a timeout, a hung # cook would block the migration script indefinitely. # The script must apply a per-recipe timeout. text = SCRIPT.read_text() self.assertRegex( text, r'timeout\s+\d+\s+./target/release/repo\s+cook', "cook call must be wrapped in `timeout N`", ) def test_diff_excludes_ecm_generated_files(self): # ECM (Extra CMake Modules) writes a `.clang-format` # config file and a `.gitignore` file during the cmake # configure step on every cook. These are NOT Red Bear # edits and would pollute the migration patch with # autogenerated noise (95+ lines for `.clang-format` # alone). The diff must exclude them. text = SCRIPT.read_text() self.assertIn( ".clang-format", text, "diff command must exclude .clang-format (ECM autogenerated)", ) self.assertIn( ".gitignore", text, "diff command must exclude .gitignore (ECM autogenerated)", ) if __name__ == "__main__": unittest.main()