"""Tests for local/scripts/scratch-rebuild.sh. The script's autotools detection is hard to test as a whole because the rebuild step requires a built cookbook binary plus cooked deps. These tests validate the parts that are testable in isolation: the AUTOTOOLS_CORE list, the content regex, and the transitive-closure BFS algorithm. The full integration test (cookbook rebuild after a relibc change) is exercised manually + via the Gitea Actions job that runs the script's --dry-run path. """ import os import re import subprocess import sys import tempfile import textwrap import unittest from pathlib import Path SCRIPT = Path(__file__).resolve().parent.parent / "scratch-rebuild.sh" def _make_recipe( root: Path, category: str, name: str, *, has_autotools: bool = False, deps: list[str] | None = None, ) -> Path: """Create a synthetic recipe with optional autotools content and deps.""" d = root / "local" / "recipes" / category / name d.mkdir(parents=True, exist_ok=True) deps_str = "" if deps: deps_str = ( "dependencies = [" + ", ".join(f'"{d}"' for d in deps) + "]" ) body = "[source]\n" body += 'tar = "https://example.com/foo.tar.xz"\n' body += 'blake3 = "deadbeef"\n\n' body += "[build]\n" if has_autotools: body += "script = \"\"\"\n" body += "aclocal -I m4\n" body += "autoreconf -fi\n" body += "./configure --prefix=/usr\n" body += "make\n" body += '"""\n' else: body += 'script = "make install"\n' if deps_str: body += "\n" + deps_str + "\n" (d / "recipe.toml").write_text(body) return d class TestAutotoolsCoreList(unittest.TestCase): """The AUTOTOOLS_CORE set is the named-list of recipes that are autotools infrastructure even if they don't directly invoke aclocal/autoreconf. Verify the constant is correct.""" def test_autotools_core_includes_m4(self): text = SCRIPT.read_text() self.assertIn("AUTOTOOLS_CORE=", text) self.assertIn("m4", text) def test_autotools_core_includes_libtool(self): text = SCRIPT.read_text() self.assertIn("AUTOTOOLS_CORE=", text) self.assertIn("libtool", text) def test_autotools_core_includes_bison_flex(self): text = SCRIPT.read_text() self.assertIn("AUTOTOOLS_CORE=", text) self.assertIn("bison", text) self.assertIn("flex", text) class TestAutotoolsContentRegex(unittest.TestCase): """The content regex is the primary detection mechanism. Verify it catches each canonical autotools command.""" REGEX = re.compile( "^([\\s]*(aclocal|autoreconf|libtoolize|automake|autoconf|gettextize)\\b|\\./configure\\b|./configure\\b)" ) def _assert_matches(self, line: str) -> None: """Run a positive match assertion with a clear error.""" self.assertTrue( bool(self.REGEX.match(line)), f"regex {self.REGEX.pattern!r} should match {line!r}", ) def test_catches_aclocal(self): self._assert_matches("aclocal -I m4") def test_catches_autoreconf(self): self._assert_matches("autoreconf -fi") def test_catches_libtoolize(self): self._assert_matches("libtoolize --force") def test_catches_automake(self): self._assert_matches("automake --add-missing") def test_catches_autoconf(self): self._assert_matches("autoconf") def test_catches_gettextize(self): self._assert_matches("gettextize") def test_catches_configure_with_dot_slash(self): self._assert_matches("./configure --prefix=/usr") def test_does_not_match_non_autotools(self): self.assertFalse(self.REGEX.match("make install")) self.assertFalse(self.REGEX.match("cmake -B build")) self.assertFalse(self.REGEX.match("meson setup builddir")) class TestRecipeDepParsing(unittest.TestCase): """The dep-closure BFS reads dependencies + dev_dependencies from each recipe.toml. Verify the awk-based parser extracts both forms correctly.""" def _parse_deps(self, recipe_text: str) -> list[str]: import re as _re in_build = False deps: list[str] = [] for line in recipe_text.splitlines(): if line.strip() == "[build]": in_build = True continue if line.strip().startswith("[") and line.strip() != "[build]": in_build = False if not in_build: continue m = _re.match( r"^\s*(dependencies|dev-dependencies)\s*=\s*\[(.*)\]\s*$", line ) if m: content = m.group(2) deps.extend( item.strip().strip('"').strip("'") for item in content.split(",") if item.strip() ) return deps def test_parses_dependencies(self): text = textwrap.dedent(""" [source] tar = "x" [build] dependencies = ["foo", "bar"] script = "make" """) self.assertEqual(self._parse_deps(text), ["foo", "bar"]) def test_parses_dev_dependencies(self): text = textwrap.dedent(""" [source] tar = "x" [build] dev-dependencies = ["dev-foo"] script = "make" """) self.assertEqual(self._parse_deps(text), ["dev-foo"]) def test_parses_both(self): text = textwrap.dedent(""" [source] tar = "x" [build] dependencies = ["foo"] dev-dependencies = ["bar"] script = "make" """) self.assertEqual(self._parse_deps(text), ["foo", "bar"]) def test_no_deps(self): text = textwrap.dedent(""" [source] tar = "x" [build] script = "make" """) self.assertEqual(self._parse_deps(text), []) class TestScriptHelp(unittest.TestCase): def test_help_describes_script(self): result = subprocess.run( [str(SCRIPT), "--help"], capture_output=True, text=True, timeout=5, ) self.assertEqual(result.returncode, 0) self.assertIn("build-system improvement #10", result.stdout) self.assertIn("--dry-run", result.stdout) self.assertIn("--jobs=", result.stdout) self.assertIn("autotools", result.stdout.lower()) class TestScriptStructure(unittest.TestCase): """Regression guards against the v1 mistakes we don't want to repeat: missing cookbook check, non-executable, wrong target dir layout.""" def test_script_is_executable(self): import os import stat mode = SCRIPT.stat().st_mode self.assertTrue(mode & stat.S_IXUSR, "script must be user-executable") def test_uses_release_repo_binary(self): text = SCRIPT.read_text() self.assertIn("./target/release/repo", text) def test_preserves_source_dir(self): # The migration must NOT delete source/ — that would # force a re-fetch. Only build/ + sysroot/ + stage.tmp/. text = SCRIPT.read_text() self.assertIn("PRESERVE source/", text) # Verify the actual deletion logic only targets the # right subdirs. for line in text.splitlines(): if "rm -rf" in line and "sub in" not in line and "build" not in line: continue if "rm -rf" in line and ("build" in line or "stage.tmp" in line): self.assertIn("arch_target", line) def test_uses_parallel_jobs_flag(self): # The script must use --jobs=N (not --jobs N) so the # parallel scheduler kicks in. text = SCRIPT.read_text() self.assertIn('--jobs="$JOBS"', text) def test_dry_run_does_not_clean(self): # When DRY_RUN=1, the script must report what it # WOULD do but not actually rm or cook. text = SCRIPT.read_text() self.assertIn("[dry-run] would", text) if __name__ == "__main__": unittest.main()