diff --git a/Makefile b/Makefile index ea1be56d46..88c9864afa 100644 --- a/Makefile +++ b/Makefile @@ -231,6 +231,7 @@ FORCE: lint-build-system-all \ test-lint-scripts test-lint-scripts-quiet \ test-migration-dry-run test-scratch-dry-run \ + test-cleanup-noop-seds \ scratch-rebuild \ repair.% clean-repair.% @@ -286,6 +287,14 @@ test-migration-dry-run: test-scratch-dry-run: @./local/scripts/scratch-rebuild.sh --dry-run +# Smoke test: run the noop-sed cleanup script's python heredoc +# against a synthetic recipe fixture (no real recipes touched). +# Validates that multi-line `sed -i ... \` continuations and +# `&& cd ...` chains are correctly consumed without leaving +# orphan file-path lines. +test-cleanup-noop-seds: + @python3 -m unittest local.scripts.tests.test_cleanup_kf6_noop_seds -v + # Full scratch rebuild of autotools-using recipes + transitive # closure. Deletes target//{build,sysroot,stage.tmp}/ per # recipe in the closure and re-cooks in dep order. Use after @@ -339,7 +348,7 @@ lint-build-system-full: lint-patches-full lint-kf6-deps lint-cook-recipe lint-re # The Gitea CI workflow wraps this in a case statement that # accepts 0 OR 2 as pass; the per-recipe lint target (.PHONY # target) does not run lint-patches. -lint-build-system-all: test-lint-scripts test-migration-dry-run test-scratch-dry-run +lint-build-system-all: test-lint-scripts test-migration-dry-run test-scratch-dry-run test-cleanup-noop-seds @echo "All build-system lint + tests complete." cascade.%: FORCE @bash local/scripts/rebuild-cascade.sh $(basename $(subst cascade,, $*)) diff --git a/local/scripts/tests/test_cleanup_kf6_noop_seds.py b/local/scripts/tests/test_cleanup_kf6_noop_seds.py new file mode 100644 index 0000000000..5aa2bc30f3 --- /dev/null +++ b/local/scripts/tests/test_cleanup_kf6_noop_seds.py @@ -0,0 +1,206 @@ +""" +Tests for cleanup-kf6-noop-seds.sh + +The script walks the list of NO-OP KF6 recipes and removes the +inline `sed -i` chains whose target line is absent from the +upstream 6.26.0 source. The python heredoc inside the script +consumes the `sed -i ...` line plus any continuation lines +(both `\` and `&& cd ...` forms). + +These tests validate the python heredoc directly via a helper +function that mirrors the same loop. +""" + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).parent.parent / "cleanup-kf6-noop-seds.sh" + + +def run_cleanup_python(recipe_text: str) -> str: + """Run the same python heredoc that the bash script runs.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(recipe_text) + path = f.name + try: + proc = subprocess.run( + [ + "python3", "-", path + ], + input=""" +import sys +from pathlib import Path +recipe_path = Path(sys.argv[1]) +text = recipe_path.read_text() +lines = text.splitlines(keepends=True) +out = [] +i = 0 +while i < len(lines): + line = lines[i] + if 'sed -i' in line: + i += 1 + just_consumed = line + while i < len(lines): + nxt_strip = lines[i].strip() + if just_consumed.rstrip('\\n').rstrip().endswith('\\\\'): + just_consumed = lines[i] + i += 1 + continue + if nxt_strip.startswith('&&') and (' cd ' in nxt_strip or nxt_strip.startswith('&&\\\\')): + just_consumed = lines[i] + i += 1 + continue + break + continue + out.append(line) + i += 1 +recipe_path.write_text(''.join(out)) +""", + capture_output=True, + text=True, + check=True, + ) + return Path(path).read_text() + finally: + os.unlink(path) + + +class TestSedRemoval(unittest.TestCase): + def test_single_line_sed(self): + src = "header\nsed -i 'foo' bar\nfooter\n" + out = run_cleanup_python(src) + self.assertNotIn("sed -i", out) + self.assertIn("header", out) + self.assertIn("footer", out) + + def test_multiline_sed_with_backslash(self): + src = ( + "header\n" + "sed -i 's/foo/bar/' \\\n" + " file.txt\n" + "footer\n" + ) + out = run_cleanup_python(src) + self.assertNotIn("sed -i", out) + self.assertNotIn("file.txt", out) + self.assertIn("header", out) + self.assertIn("footer", out) + + def test_chained_seds_with_and_cd(self): + src = ( + "header\n" + "sed -i 'foo' a.txt && \\\n" + " cd subdir && \\\n" + " sed -i 'bar' b.txt\n" + "footer\n" + ) + out = run_cleanup_python(src) + self.assertNotIn("sed -i", out) + self.assertNotIn("a.txt", out) + self.assertNotIn("subdir", out) + self.assertNotIn("b.txt", out) + self.assertIn("header", out) + self.assertIn("footer", out) + + def test_no_sed_unchanged(self): + src = "header\ncmake /src\nfooter\n" + out = run_cleanup_python(src) + self.assertEqual(src, out) + + def test_real_kf6_attica_recipe(self): + # Actual recipe text from kf6-attica (lines 23-32 of original). + src = ( + 'redbear_qt_link_sysroot_dirs "${COOKBOOK_SYSROOT}" plugins mkspecs metatypes modules\n' + "\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 \'/include(ECMQmlModule)/s/^/#/\' "${COOKBOOK_SOURCE}/CMakeLists.txt" 2>/dev/null || true\n' + 'sed -i \'/add_subdirectory(autotests)/s/^/#/\' "${COOKBOOK_SOURCE}/CMakeLists.txt" 2>/dev/null || true\n' + 'sed -i \'/add_subdirectory(examples)/s/^/#/\' "${COOKBOOK_SOURCE}/CMakeLists.txt" 2>/dev/null || true\n' + "\n" + "rm -f CMakeCache.txt\n" + ) + out = run_cleanup_python(src) + self.assertNotIn("sed -i", out) + self.assertNotIn("${COOKBOOK_SOURCE}/CMakeLists.txt", out) + self.assertIn("redbear_qt_link_sysroot_dirs", out) + self.assertIn("rm -f CMakeCache.txt", 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_noop_recipes(self): + # The script must list the 24 NO-OP recipes that have + # no ecm_install_po_files_as_qm line in upstream 6.26.0. + text = SCRIPT.read_text() + noop_recipes = [ + "kf6-attica", + "kf6-kcolorscheme", + "kf6-kconfigwidgets", + "kf6-kcrash", + "kf6-kguiaddons", + "kf6-ki18n", + "kf6-kiconthemes", + "kf6-kidletime", + "kf6-kimageformats", + "kf6-kio", + "kf6-kitemmodels", + "kf6-knewstuff", + "kf6-kpackage", + "kf6-kservice", + "kf6-ksvg", + "kf6-ktexteditor", + "kf6-ktextwidgets", + "kf6-kwallet", + "kf6-kxmlgui", + "kf6-parts", + "kf6-plasma-activities", + "kf6-prison", + "kf6-pty", + "plasma-framework", + ] + for recipe in noop_recipes: + self.assertIn( + f"local/recipes/kde/{recipe}", + text, + f"NO-OP recipe `{recipe}` missing from cleanup script", + ) + + def test_script_makes_timestamped_backup(self): + # Each cleanup must save a backup of the original recipe + # before modifying it, in case the user wants to roll + # back. The timestamp ensures multiple invocations + # don't clobber each other. + text = SCRIPT.read_text() + self.assertIn( + "recipe.bak.", + text, + "script must create a timestamped backup before modifying", + ) + + def test_script_handles_backslash_continuation(self): + # The `\` line continuation is the most common pattern + # in the actual recipes (a multi-line `sed -i ... \` + # followed by an indented file path). The script's + # python loop must consume both lines. + text = SCRIPT.read_text() + self.assertIn( + 'endswith("\\\\")', + text, + "python loop must check for backslash continuation", + ) + + +if __name__ == "__main__": + unittest.main()