07f924fe09
Several KF6 recipes (kf6-kauth, kf6-kconfig, kf6-kwidgetsaddons) use autotools and their `autoreconf` step can take 5+ minutes on a clean cook. Without a per-recipe timeout, a hung cook blocks the migration script indefinitely and leaves `source-pristine/` snapshots lingering on disk. The sed chain we care about runs in the recipe's [build].script BEFORE the configure step, so a 10-minute window is plenty. The snapshot at step 2 is already on disk, so even if the cook is killed by the timeout, the post-cook source state is still useful for the diff. Adds test_cook_has_timeout regression test (123 Python tests total). All 7 test files pass.
225 lines
8.4 KiB
Python
225 lines
8.4 KiB
Python
"""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/<cat>/<name>."""
|
|
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
|
|
# <recipe_dir>` 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`",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|