Files
RedBear-OS/local/scripts/tests/test_migrate_kf6_seds.py
T
kellito b8c1c780dc build: ship first C-7 KF6 sed migration patch (kf6-karchive)
First durable artifact from the C-7 KF6 sed migration: the
inline sed -i chains in local/recipes/kde/kf6-karchive's
[build].script have been captured as a durable external
patch in local/patches/kf6-karchive/01-initial-migration.patch.

This patch was generated by running the v2 migration
script (commit 827895d32) against the live kf6-karchive
recipe. The actual sed edits captured are:

  -ecm_install_po_files_as_qm(poqm)
  +#ecm_install_po_files_as_qm(poqm)

The other 3 sed chains in the recipe (ki18n_install(po),
.arg(mode), .arg(d->mode)) were no-ops against the karchive
6.26.0 upstream tar (the target lines either no longer
exist or are already in the desired state in this upstream
version). The migration script correctly captures only the
real edits; no-ops produce no patch hunks.

Script fix in this commit:

The migration script's v2 was producing silently empty
diffs on already-cooked recipes because the cookbook's
`fetch` re-uses an existing source/ tree if it finds one
(it does this to avoid re-extracting tars on every fetch).
For C-7 migration we need the truly pristine upstream
state. The fix:
  1. Add an explicit `unfetch` step BEFORE the `fetch`
     (so the source/ dir is removed before re-extraction)
  2. Set `REDBEAR_ALLOW_LOCAL_UNFETCH=1` because kf6-*
     and qt* recipes are local-overlay recipes under
     local/recipes/, and the cookbook's default policy is
     to never clobber a local-overlay source (the env var
     overrides that policy for the migration's explicit
     unfetch call only)
  3. Apply the same env var to the post-capture `unfetch`
     at the end of the script

The script header documents this cookbook behavior with
inline comments so a future contributor doesn't re-introduce
the silent-failure mode.

Patch filter:

The migration script's diff includes ECM-autogenerated
files like .clang-format that aren't real sed edits. The
captured patch was 122 lines, of which 95 were the
.clang-format autogeneration. The committed patch is the
filtered 24-line version that drops `.clang-format`,
`.gitignore`, and any `target/` artifacts. (A future
script improvement could do this filter inline.)

Test count: 120 -> 122 (2 new tests in test_migrate_kf6_seds.py):
  - test_sets_local_unfetch_env_var: regression guard
    against forgetting the env var
  - test_unfetches_before_fetching: regression guard
    against calling fetch before unfetch (silent-failure
    mode in v2)

Next steps for kf6-karchive specifically (manual, not part
of this commit):
  1. Edit local/recipes/kde/kf6-karchive/recipe.toml's
     [build].script to remove the 4 inline sed -i chains
     and add:
         REDBEAR_PATCHES_DIR="local/patches/kf6-karchive"
         cookbook_apply_patches "${REDBEAR_PATCHES_DIR}"
  2. Cook again to verify the patch + rewritten script
     produce a byte-identical stage.pkgar
  3. Commit the recipe rewrite + the patch together

Verified:
  - The migration ran end-to-end on the live tree
  - The patch applies cleanly to the pristine upstream
  - 122/122 Python tests pass
  - The new test_sets_local_unfetch_env_var and
    test_unfetches_before_fetching both pass

C-7 status: 1 of 56 KF6 sed-bearing recipes migrated.
55 remaining (next: kf6-attica has the smallest sed chain;
after that, breeze, kf6-syntaxhighlighting).
2026-06-12 17:05:46 +03:00

212 lines
7.9 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")
if __name__ == "__main__":
unittest.main()