#!/usr/bin/env python3 """Smoke tests for classify-cook-failure.py. Run with: python3 -m unittest local/scripts/tests/test_classify_cook_failure.py or cd local/scripts && python3 -m unittest discover -s tests """ import json import re import sys import unittest from pathlib import Path SCRIPTS_DIR = Path(__file__).resolve().parent.parent sys.path.insert(0, str(SCRIPTS_DIR)) import importlib.util # noqa: E402 _spec = importlib.util.spec_from_file_location( "ccf", SCRIPTS_DIR / "classify-cook-failure.py" ) assert _spec is not None and _spec.loader is not None ccf = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(ccf) class TestRuleFires(unittest.TestCase): """Each of the 17 rules must fire on a synthetic log that exercises it.""" def test_rule_4_kfilesystemtype_fires(self): log = ( "[ 12%] Building CXX object kfilesystemtype.cpp.o\n" "kfilesystemtype.cpp:42:1: error: determineFileSystemTypeImpl " "was not declared in this scope" ) self.assertTrue( _matches(ccf.RULES, log, "kfilesystemtype static function collision") ) def test_rule_7_ninja_fires(self): log = "ninja: error: No such file. CMake Error: ninja-build missing" self.assertTrue(_matches(ccf.RULES, log, "ninja not found in sysroot")) def test_rule_9_libmount_fires(self): log = "CMake Error: Could NOT find LibMount (missing: LibMount_DIR)" self.assertTrue(_matches(ccf.RULES, log, "LibMount missing (kf6-kio)")) def test_rule_10_qfloat16_fires(self): log = "undefined reference to `__extendhfdf2'" self.assertTrue(_matches(ccf.RULES, log, "qfloat16 linker error (libsoftfloat missing)")) def test_rule_11_kconfig_stale_fires(self): log = ( 'CMake Error at CMakeLists.txt:42 (find_package):\n' ' Found unsuitable version "5.103.0" of KF6CoreAddons, ' 'but KF6Config requires exactly "6.26.0"' ) self.assertTrue(_matches(ccf.RULES, log, "kconfig stale sysroot (KF6CoreAddons version mismatch)")) class TestRuleDoesNotFire(unittest.TestCase): """Generic C++ errors must NOT trigger narrowly-scoped rules.""" def test_rule_4_does_not_fire_on_generic_cpp_error(self): log = "bar.cpp:1:1: error: two or more data types in declaration specifiers" # No kfilesystemtype in log -> context_required gate blocks the rule. self.assertFalse(_matches(ccf.RULES, log, "kfilesystemtype static function collision")) def test_rule_11_does_not_fire_on_openssl_error(self): log = ( "CMake Error: Could NOT find OpenSSL (missing: OpenSSL_DIR)\n" "Found unsuitable version \"1.1\", but required is 3.0\n" "(looked in KF6Config.cmake)" ) # KF6CoreAddons NOT mentioned -> context_required gate blocks it. self.assertFalse(_matches(ccf.RULES, log, "kconfig stale sysroot")) def test_rule_11_does_not_fire_on_clean_log(self): log = "Built target relibc\nBuilt target base\n[100%] Built target all" self.assertFalse(_matches(ccf.RULES, log, "kconfig stale sysroot")) class TestExitCodeSemantics(unittest.TestCase): """--json exit code must be 1 if a rule matches, 0 if not (CI-safe).""" def setUp(self): self.tmp_log = Path("/tmp/ccf-test-exit-match.txt") self.tmp_log.write_text( "kfilesystemtype.cpp:42: error: determineFileSystemTypeImpl " "was not declared in this scope" ) self.tmp_clean = Path("/tmp/ccf-test-exit-clean.txt") self.tmp_clean.write_text("Built target all\n[100%] Built target") def tearDown(self): for p in (self.tmp_log, self.tmp_clean): if p.exists(): p.unlink() def test_matched_log_exits_1(self): import subprocess rc = subprocess.run( ["python3", str(SCRIPTS_DIR / "classify-cook-failure.py"), str(self.tmp_log), "--json"], capture_output=True, text=True, ).returncode # Exit 1 == "I identified a known failure" (CI signal that a # fix is available). Exit 0 == "no known pattern matched" # (novel failure, needs human triage). self.assertEqual(rc, 1, f"expected 1 (matched), got {rc}") def test_clean_log_exits_0(self): import subprocess rc = subprocess.run( ["python3", str(SCRIPTS_DIR / "classify-cook-failure.py"), str(self.tmp_clean), "--json"], capture_output=True, text=True, ).returncode self.assertEqual(rc, 0, f"expected 0 (clean), got {rc}") class TestExplainRule(unittest.TestCase): def test_explain_rule_kfilesystemtype(self): import subprocess out = subprocess.run( ["python3", str(SCRIPTS_DIR / "classify-cook-failure.py"), "--explain-rule", "kfilesystem"], capture_output=True, text=True, ) self.assertIn("RULE: kfilesystemtype", out.stdout) self.assertIn("Context required:", out.stdout) def _matches(rules, log, target_name): """Return True if any rule whose name STARTS WITH `target_name` matches `log`. Substring match (rather than exact match) lets the test file use short, human-readable rule names like "kconfig stale sysroot" that match the full rule name "kconfig stale sysroot (KF6CoreAddons version mismatch)". If multiple rules share the prefix, the first one that matches the log wins. """ for r in rules: if not r["name"].lower().startswith(target_name.lower()): continue patterns = r["patterns"] if not all(re.search(p, log) for p in patterns): continue context = r.get("context_required") if context and not all(tok in log for tok in context): continue return True return False class TestUntestedRules(unittest.TestCase): """Cover the 12 rules that have NO test in TestRuleFires. These rules are exercised in real cooks but lack synthetic-log coverage. The tests below are intentionally minimal — a one-line log that exercises the rule's pattern + any context_required gate. """ def test_rule_0_glesv2_fires(self): log = "CMake Error: Could NOT find GLESv2 (missing: GLESv2_DIR)" self.assertTrue(_matches(ccf.RULES, log, "GLESv2 / Qt6Gui visibility")) def test_rule_1_kiconloader_fires(self): # Real GCC linker output: "undefined reference to `KIconLoader::instance'" # (the `.` in the regex matches the backtick before KIconLoader) log = "undefined reference to vKIconLoader::instance" self.assertTrue(_matches(ccf.RULES, log, "KIconLoader undefined reference")) def test_rule_3_cxx20_ranges_fires(self): log = "error: 'std::ranges' has not been declared" self.assertTrue(_matches(ccf.RULES, log, "C++20 std::ranges not declared")) def test_rule_4_qt6guifrivate_fires(self): log = "CMake Error: Could NOT find Qt6GuiPrivate" self.assertTrue(_matches(ccf.RULES, log, "Qt6::GuiPrivate not found")) def test_rule_5_plasmawaylandprotocols_fires(self): log = "By not providing PlasmaWaylandProtocols the recipe failed to find" self.assertTrue(_matches(ccf.RULES, log, "PlasmaWaylandProtocols path-doubling bug")) def test_rule_10_libc_so_6_fires(self): log = "/usr/bin/ld: warning: libc.so.6 not found, treating as static" self.assertTrue(_matches(ccf.RULES, log, "libc.so.6 not found")) def test_rule_11_gettext_fires(self): log = "gettext-tools: ./configure failed: HAVE_STDBOOL not defined" self.assertTrue(_matches(ccf.RULES, log, "gettext gnulib rebuild loop")) def test_rule_12_python3_fires(self): log = "CMake Error: Python3 Development not found (missing: Python3_LIBRARIES)" self.assertTrue(_matches(ccf.RULES, log, "Python3 development headers missing")) def test_rule_13_cookbook_apply_patches_fires(self): log = "cookbook_apply_patches: FAILED to apply 02-redox-dispatch.patch" self.assertTrue(_matches(ccf.RULES, log, "cookbook_apply_patches")) def test_rule_14_package_not_found_fires(self): log = "Cookbook error: Package 'kf6-kimageformats' not found in any active filesystem" self.assertTrue(_matches(ccf.RULES, log, "Package not found")) def test_rule_15_qvariant_fires(self): # Real qApp->property() in a private header produces a stack # trace like this. The rule's pattern uses [\s\S]{0,N} to span # the lines. The context_required gate is QString + QCoreApplication # — both must appear in the log for the rule to fire. log = ( "[ 50%] Building CXX object foo.cpp.o\n" "In file included from /usr/include/QtCore/QString:1\n" "In file included from /usr/include/QtCore/QCoreApplication:1\n" "foo.cpp:42:1: error: 'QVariant' was not declared in this scope\n" " auto v = qApp->property(\"kde.foo\").toString();\n" " ^~~~~~~" ) self.assertTrue(_matches(ccf.RULES, log, "QVariant not declared")) def test_rule_16_fetch_denied_fires(self): log = "Cookbook: relibc is not exist and unable to continue in offline mode" self.assertTrue(_matches(ccf.RULES, log, "fetch denied")) class TestRuleFalsePositives(unittest.TestCase): """Negative cases: synthetic logs that should NOT trigger rules. These exist to catch future regex over-broadening regressions. Each test constructs a log that LOOKS similar to a real rule trigger but is missing a required context or pattern piece. """ def test_rule_0_glesv2_does_not_fire_without_keyword(self): # "OpenGL" alone should not trigger the GLESv2 rule log = "warning: OpenGL ES 2.0 is preferred but unavailable" self.assertFalse(_matches(ccf.RULES, log, "GLESv2 / Qt6Gui visibility")) def test_rule_1_kiconloader_does_not_fire_for_ki18n(self): # Similar prefix, different symbol log = "undefined reference to `KLocalizedString::localizedString'" self.assertFalse(_matches(ccf.RULES, log, "KIconLoader undefined reference")) def test_rule_3_cxx20_does_not_fire_for_std_string(self): log = "error: 'std::string' has not been declared" self.assertFalse(_matches(ccf.RULES, log, "C++20 std::ranges not declared")) def test_rule_4_qt6guifrivate_does_not_fire_for_qt6core(self): log = "CMake Error: Could NOT find Qt6Core (missing: Qt6Core_DIR)" self.assertFalse(_matches(ccf.RULES, log, "Qt6::GuiPrivate not found")) def test_rule_5_plasmawaylandprotocols_does_not_fire_unrelated(self): # The string "PlasmaWaylandProtocols" must appear to trigger # the rule. A log about wayland-protocols without the # Plasma prefix should not match. log = "wayland-protocols not found in sysroot" self.assertFalse(_matches(ccf.RULES, log, "PlasmaWaylandProtocols path-doubling bug")) def test_rule_10_libc_does_not_fire_for_libpthread(self): log = "/usr/bin/ld: libpthread.so.0: cannot open shared object file: not found" self.assertFalse(_matches(ccf.RULES, log, "libc.so.6 not found")) def test_rule_11_gettext_does_not_fire_unrelated(self): log = "gettext is missing, install gettext first" self.assertFalse(_matches(ccf.RULES, log, "gettext gnulib rebuild loop")) def test_rule_12_python3_does_not_fire_unrelated(self): # The exact phrases are required log = "Python interpreter not found in PATH" self.assertFalse(_matches(ccf.RULES, log, "Python3 development headers missing")) def test_rule_13_cookbook_apply_patches_does_not_fire_on_cookbook_msgs(self): # The cookbook logs MANY cookbook_apply_patches lines on # every successful cook. Only FAILED lines should fire. log = "cookbook_apply_patches: applied 02-redox-dispatch.patch successfully" self.assertFalse(_matches(ccf.RULES, log, "cookbook_apply_patches")) def test_rule_14_package_not_found_does_not_fire_unrelated(self): # Need "Package not found" — note the word boundary log = "warning: package was not found in any cache" self.assertFalse(_matches(ccf.RULES, log, "Package not found")) def test_rule_15_qvariant_does_not_fire_without_qapp(self): # Without qApp[\s\S]{0,N}property within range, the rule # must not fire. Real QVariant errors are usually just the # "not declared" line, not the full multi-line stack trace. log = "QVariant not declared" self.assertFalse(_matches(ccf.RULES, log, "QVariant not declared")) def test_rule_16_fetch_denied_does_not_fire_unrelated(self): # Must match either of the two specific phrases log = "Cookbook: unable to fetch in offline mode" self.assertFalse(_matches(ccf.RULES, log, "fetch denied")) if __name__ == "__main__": unittest.main()