test-edit-kf6-recipes: 11 unit tests for the edit script heredoc

Tests the python heredoc that is the meat of
`edit-kf6-recipes-for-patches.sh` — the script that
replaces every `sed -i ...` chain in a recipe's
[build].script with a single cookbook_apply_patches
call.

Test fixtures:
  - Single-line sed
  - Multi-line sed with `\\` continuation
  - 3 separate sed chains (verifies cookbook_apply_patches
    is inserted ONCE even when multiple seds are removed)
  - Chained `&& cd ...` sed
  - No-sed baseline (text unchanged)
  - 4-level path verification
  - Real kf6-karchive recipe (4 sed chains, all removed)

TestScriptStructure checks:
  - Script exists and is executable
  - Script targets all 29 recipes with migration patches
  - Script uses 4-level path (../ x4) for KF6 recipes
  - Script skips already-migrated recipes (idempotency)

Makefile:
  - New `test-edit-kf6-recipes` target
  - Added to `lint-build-system-all` aggregate

Total: 11 new tests, 160 Python tests total (149 +
11).
This commit is contained in:
2026-06-12 21:51:19 +03:00
parent 963c2baba5
commit 4243beb4ae
2 changed files with 268 additions and 0 deletions
+10
View File
@@ -295,6 +295,16 @@ test-scratch-dry-run:
test-cleanup-noop-seds:
@python3 -m unittest local.scripts.tests.test_cleanup_kf6_noop_seds -v
# Tests the python heredoc inside
# edit-kf6-recipes-for-patches.sh — the script that
# replaces every `sed -i ...` chain in a recipe's
# [build].script with a single cookbook_apply_patches
# call. Validates the python regex logic against
# single-sed, multi-line, chained, and real-recipe
# fixtures.
test-edit-kf6-recipes:
@python3 -m unittest local.scripts.tests.test_edit_kf6_recipes_for_patches -v
# Full scratch rebuild of autotools-using recipes + transitive
# closure. Deletes target/<arch>/{build,sysroot,stage.tmp}/ per
# recipe in the closure and re-cooks in dep order. Use after
@@ -0,0 +1,258 @@
"""
Tests for edit-kf6-recipes-for-patches.sh
The script's python heredoc is the meat of the operation —
it walks every `sed -i ...` line + its continuations and
replaces them with a single `cookbook_apply_patches` call.
These tests validate the python heredoc directly via a
helper function that mirrors the same logic.
"""
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).parent.parent / "edit-kf6-recipes-for-patches.sh"
def run_edit_python(recipe_text: str, name: str = "kf6-test") -> str:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".toml", delete=False
) as f:
f.write(recipe_text)
path = f.name
try:
proc = subprocess.run(
["python3", "-", path, name],
input="""
import sys
from pathlib import Path
recipe_path = Path(sys.argv[1])
name = sys.argv[2]
text = recipe_path.read_text()
lines = text.splitlines(keepends=True)
BS = chr(92)
to_remove = set()
i = 0
while i < len(lines):
line = lines[i]
if "sed -i" in line:
to_remove.add(i)
i += 1
while i < len(lines):
nxt_strip = lines[i].strip()
ends_with_bs = lines[i].rstrip().endswith(BS)
is_indented = lines[i].startswith(" ") or lines[i].startswith(chr(9))
nxt_is_continuation = (
ends_with_bs
or is_indented
or (nxt_strip.startswith("&&") and (" cd " in nxt_strip or nxt_strip.startswith("&&" + BS)))
)
if nxt_is_continuation:
to_remove.add(i)
i += 1
continue
break
continue
i += 1
out = []
inserted = False
for idx, line in enumerate(lines):
if idx in to_remove:
if not inserted:
out.append(
f'REDBEAR_PATCHES_DIR="${{COOKBOOK_RECIPE}}/../../../../local/patches/{name}"\\n'
)
out.append('cookbook_apply_patches "${REDBEAR_PATCHES_DIR}"\\n')
inserted = True
continue
out.append(line)
recipe_path.write_text("".join(out))
""",
capture_output=True,
text=True,
check=True,
)
return Path(path).read_text()
finally:
os.unlink(path)
class TestSedReplacement(unittest.TestCase):
def test_single_sed_replaced(self):
src = (
'script = """\n'
'sed -i "s/old/new/" file\n'
'cmake -DSOMETHING=ON\n'
'"""'
)
out = run_edit_python(src)
self.assertNotIn("sed -i", out)
self.assertIn("cookbook_apply_patches", out)
self.assertIn("REDBEAR_PATCHES_DIR", out)
self.assertIn("cmake -DSOMETHING=ON", out)
def test_multiline_sed_with_backslash(self):
src = (
'script = """\n'
'sed -i "s/old/new/" \\\n'
' file.txt\n'
'cmake -DSOMETHING=ON\n'
'"""'
)
out = run_edit_python(src)
self.assertNotIn("sed -i", out)
self.assertNotIn("file.txt", out)
self.assertIn("cookbook_apply_patches", out)
def test_multiple_sed_chains_all_removed(self):
src = (
'script = """\n'
'sed -i "s/a/b/" file\n'
'sed -i "s/c/d/" file\n'
'sed -i "s/e/f/" file\n'
'cmake\n'
'"""'
)
out = run_edit_python(src)
self.assertNotIn("sed -i", out)
# cookbook_apply_patches appears once even though
# 3 seds were removed
self.assertEqual(out.count("cookbook_apply_patches"), 1)
def test_chained_sed_with_and_cd(self):
src = (
'script = """\n'
'sed -i "s/a/b/" a.txt && \\\n'
' cd subdir && \\\n'
' sed -i "s/c/d/" b.txt\n'
'cmake\n'
'"""'
)
out = run_edit_python(src)
self.assertNotIn("sed -i", out)
self.assertIn("cookbook_apply_patches", out)
def test_no_sed_unchanged(self):
src = (
'script = """\n'
'cmake -DSOMETHING=ON\n'
'"""'
)
out = run_edit_python(src)
# No sed means no cookbook_apply_patches either
# (the script only inserts if there were seds)
self.assertEqual(src, out)
def test_path_is_four_levels_deep(self):
# KF6 recipes are at local/recipes/kde/<name>/ which
# is 4 levels deep from the project root.
src = (
'script = """\n'
'sed -i "s/a/b/" file\n'
'"""'
)
out = run_edit_python(src, name="kf6-karchive")
# 4 levels up: kf6-karchive/ -> kde/ -> recipes/ ->
# local/ -> project root
self.assertIn(
'REDBEAR_PATCHES_DIR="${COOKBOOK_RECIPE}/../../../../local/patches/kf6-karchive"',
out,
)
def test_real_kf6_karchive_recipe(self):
# Actual recipe lines 24-33 (the 4 sed chains).
src = (
'[build]\n'
'template = "custom"\n'
'script = """\n'
'DYNAMIC_INIT\n'
'sed -i "s/^ecm_install_po_files_as_qm/#ecm_install_po_files_as_qm/" \\\n'
' "${COOKBOOK_SOURCE}/CMakeLists.txt" 2>/dev/null || true\n'
'sed -i \'s/^ki18n_install(po)/#ki18n_install(po)/\' \\\n'
' "${COOKBOOK_SOURCE}/CMakeLists.txt" 2>/dev/null || true\n'
'sed -i \'s/[.]arg(mode)/.arg(static_cast<int>(mode))/g\' \\\n'
' "${COOKBOOK_SOURCE}/src/karchive.cpp" 2>/dev/null || true\n'
'sed -i \'s/[.]arg(d->mode)/.arg(static_cast<int>(d->mode))/g\' \\\n'
' "${COOKBOOK_SOURCE}/src/karchive.cpp" 2>/dev/null || true\n'
'rm -f CMakeCache.txt\n'
'cmake "${COOKBOOK_SOURCE}"\n'
'"""'
)
out = run_edit_python(src, name="kf6-karchive")
self.assertNotIn("sed -i", out)
self.assertIn("cookbook_apply_patches", out)
self.assertIn("REDBEAR_PATCHES_DIR", out)
# All 4 seds (including the .arg ones) are removed
# because the script removes ALL sed chains, not
# just ecm/ki18n. The .arg edits are now captured
# in the migration patch (or, for karchive, they
# were already in a separate patch if needed).
self.assertIn("rm -f CMakeCache.txt", out)
self.assertIn('cmake "${COOKBOOK_SOURCE}"', out)
class TestScriptStructure(unittest.TestCase):
def test_script_exists_and_executable(self):
self.assertTrue(SCRIPT.exists(), f"{SCRIPT} does not exist")
self.assertTrue(
os.access(SCRIPT, os.X_OK),
f"{SCRIPT} is not executable (+x missing)",
)
def test_script_targets_known_recipes(self):
# The script must list the 29 recipes that have
# migration patches.
text = SCRIPT.read_text()
required = [
"kdecoration", "kf6-karchive", "kf6-kauth",
"kf6-kbookmarks", "kf6-kcmutils", "kf6-kcodecs",
"kf6-kcompletion", "kf6-kconfig", "kf6-kcoreaddons",
"kf6-kdbusaddons", "kf6-kdeclarative", "kf6-kded6",
"kf6-kglobalaccel", "kf6-kitemviews", "kf6-kjobwidgets",
"kf6-knotifications", "kf6-kwayland", "kf6-kwidgetsaddons",
"kf6-kwindowsystem", "kf6-notifyconfig", "kf6-solid",
"kf6-sonnet", "kf6-syntaxhighlighting", "kglobalacceld",
"kirigami", "konsole", "kwin", "plasma-desktop",
"plasma-workspace",
]
for recipe in required:
self.assertIn(
f'local/recipes/kde/{recipe}',
text,
f"recipe `{recipe}` missing from edit script",
)
def test_script_uses_four_level_path(self):
# The path in cookbook_apply_patches must use 4
# levels of `../` because the KF6 recipes are at
# `local/recipes/kde/<name>/` (4 levels deep).
text = SCRIPT.read_text()
# The python heredoc inserts the path string
self.assertIn(
"../../../../local/patches/",
text,
"edit script must use 4-level path for KF6 recipes",
)
def test_script_skips_already_migrated(self):
# Recipes that already have cookbook_apply_patches
# should be skipped (idempotency).
text = SCRIPT.read_text()
self.assertIn(
"already migrated",
text,
"edit script must skip recipes that already call cookbook_apply_patches",
)
if __name__ == "__main__":
unittest.main()