diff --git a/codeflash/languages/java/gradle_strategy.py b/codeflash/languages/java/gradle_strategy.py index b4481dd6e..02e9105b4 100644 --- a/codeflash/languages/java/gradle_strategy.py +++ b/codeflash/languages/java/gradle_strategy.py @@ -589,6 +589,13 @@ def _get_classpath_uncached( logger.error("Classpath not found in Gradle output") return None + # Replace missing project dependency JARs with class/resource directories. + # Gradle's testRuntimeClasspath resolves project deps to JAR files + # (build/libs/*.jar), but testClasses doesn't build JARs for dependency + # modules. This also compiles any testRuntimeOnly project deps that + # weren't compiled by testClasses. + classpath = self._resolve_project_classpath(classpath, build_root, env, timeout) + if test_module: module_path = build_root / module_to_dir(test_module) else: @@ -603,15 +610,6 @@ def _get_classpath_uncached( if main_classes.exists(): cp_parts.append(str(main_classes)) - if test_module: - module_dir_name = module_to_dir(test_module) - for module_dir in build_root.iterdir(): - if module_dir.is_dir() and module_dir.name != module_dir_name: - module_classes = module_dir / "build" / "classes" / "java" / "main" - if module_classes.exists(): - logger.debug("Adding multi-module classpath: %s", module_classes) - cp_parts.append(str(module_classes)) - if "console-standalone" not in classpath and "ConsoleLauncher" not in classpath: console_jar = _find_junit_console_standalone() if console_jar: @@ -639,6 +637,121 @@ def _parse_classpath_output(stdout: str) -> str | None: return line.strip() return None + def _resolve_project_classpath(self, classpath: str, build_root: Path, env: dict[str, str], timeout: int) -> str: + """Replace missing project dependency JARs with class/resource directories. + + Gradle's ``testRuntimeClasspath`` resolves project dependencies to JAR + files (``build/libs/*.jar``), but ``testClasses`` only compiles classes — + it does not package JARs for dependency modules. This method: + + 1. Scans the classpath for project-local JARs that don't exist on disk. + 2. Compiles any dependency modules whose classes haven't been built yet + (e.g. ``testRuntimeOnly`` project deps skipped by ``testClasses``). + 3. Replaces each missing JAR entry with the module's compiled-class and + processed-resource directories. + """ + build_root_str = str(build_root) + build_libs_sep = os.sep + "build" + os.sep + "libs" + os.sep + + entries = classpath.split(os.pathsep) + missing_jar_modules: dict[int, Path] = {} # index -> module_dir + uncompiled_modules: list[str] = [] + + # Phase 1: identify missing project dependency JARs + for i, entry in enumerate(entries): + if not entry.startswith(build_root_str) or build_libs_sep not in entry: + continue + if Path(entry).exists(): + continue + + idx = entry.find(build_libs_sep) + module_dir = Path(entry[:idx]) + if not module_dir.is_dir(): + continue + + missing_jar_modules[i] = module_dir + + classes_dir = module_dir / "build" / "classes" + if not classes_dir.exists() or not any(classes_dir.iterdir()): + try: + rel = module_dir.relative_to(build_root) + uncompiled_modules.append(str(rel).replace(os.sep, ":")) + except ValueError: + pass + + if not missing_jar_modules: + return classpath + + # Phase 2: compile uncompiled dependency modules in one Gradle call + if uncompiled_modules: + self._compile_dependency_modules(build_root, env, uncompiled_modules, timeout) + + # Phase 3: replace each missing JAR with class + resource directories + result_entries: list[str] = [] + for i, entry in enumerate(entries): + if i not in missing_jar_modules: + result_entries.append(entry) + continue + + module_dir = missing_jar_modules[i] + added = False + + classes_dir = module_dir / "build" / "classes" + if classes_dir.exists(): + for lang_dir in classes_dir.iterdir(): + if lang_dir.is_dir(): + main_dir = lang_dir / "main" + if main_dir.exists(): + result_entries.append(str(main_dir)) + added = True + + resources_main = module_dir / "build" / "resources" / "main" + if resources_main.exists(): + result_entries.append(str(resources_main)) + added = True + + if not added: + logger.warning("No class/resource directories found for missing JAR: %s", entry) + + logger.debug( + "Replaced %d missing project dependency JARs with class/resource directories (compiled %d modules)", + len(missing_jar_modules), + len(uncompiled_modules), + ) + return os.pathsep.join(result_entries) + + def _compile_dependency_modules( + self, build_root: Path, env: dict[str, str], module_names: list[str], timeout: int + ) -> None: + """Compile project dependency modules that haven't been compiled yet. + + This handles ``testRuntimeOnly`` project dependencies that ``testClasses`` + does not compile. All modules are compiled in a single Gradle invocation. + """ + from codeflash.languages.java.test_runner import _run_cmd_kill_pg_on_timeout + + gradle = self.find_executable(build_root) + if not gradle: + logger.warning("Gradle not found — cannot compile dependency modules") + return + + tasks = [f":{module}:classes" for module in module_names] + cmd = [gradle, *tasks, "--no-daemon"] + cmd.extend(["--init-script", _get_skip_validation_init_script()]) + + logger.info("Compiling %d uncompiled project dependencies: %s", len(module_names), module_names) + + try: + result = _run_cmd_kill_pg_on_timeout(cmd, cwd=build_root, env=env, timeout=timeout) + if result.returncode != 0: + logger.warning( + "Failed to compile dependency modules (exit %d): %s", + result.returncode, + result.stderr[-1000:] if result.stderr else "", + ) + except Exception: + logger.exception("Exception compiling dependency modules") + def get_reports_dir(self, build_root: Path, test_module: str | None) -> Path: build_dir = self.get_build_output_dir(build_root, test_module) return build_dir / "test-results" / "test" diff --git a/codeflash/languages/java/test_runner.py b/codeflash/languages/java/test_runner.py index 74830d436..444e0a9f6 100644 --- a/codeflash/languages/java/test_runner.py +++ b/codeflash/languages/java/test_runner.py @@ -202,15 +202,43 @@ def _validate_test_filter(test_filter: str) -> str: def _extract_modules_from_settings_gradle(content: str) -> list[str]: """Extract module names from settings.gradle(.kts) content. - Looks for include directives like: - include("module-a", "module-b") // Kotlin DSL - include 'module-a', 'module-b' // Groovy DSL - Module names may be prefixed with ':' which is stripped. + Collects every quoted string that looks like a Gradle module identifier + (word chars, hyphens, colons, dots). This is intentionally broad so it + works regardless of DSL style — ``include("a")``, ``listOf("a")``, + variable-based indirection, etc. False positives are harmless because + callers match module names against actual file paths. """ + seen: set[str] = set() modules: list[str] = [] - for match in re.findall(r"""include\s*\(?[^)\n]*\)?""", content): - for name in re.findall(r"""['"]([^'"]+)['"]""", match): - modules.append(name.lstrip(":")) + for name in re.findall(r"""['"]([:\w][\w.:-]*)['"]""", content): + clean = name.lstrip(":") + if clean and clean not in seen: + seen.add(clean) + modules.append(clean) + return modules + + +def _scan_filesystem_for_modules(directory: Path) -> list[str]: + """Detect Gradle/Maven sub-modules by scanning for build files on disk. + + Checks up to two directory levels (covers nested modules like + ``connect/runtime``). This is a reliable fallback when the settings + file uses complex DSL that text-based parsing cannot handle. + """ + modules: list[str] = [] + build_files = ("build.gradle", "build.gradle.kts", "pom.xml") + for child in directory.iterdir(): + if not child.is_dir() or child.name.startswith("."): + continue + if any((child / bf).exists() for bf in build_files): + modules.append(child.name) + # One level deeper for nested modules (connect/runtime) + for grandchild in child.iterdir(): + if not grandchild.is_dir() or grandchild.name.startswith("."): + continue + if any((grandchild / bf).exists() for bf in build_files): + rel = grandchild.relative_to(directory) + modules.append(str(rel).replace(os.sep, ":")) return modules @@ -237,7 +265,8 @@ def _detect_modules(directory: Path) -> list[str]: except Exception: pass - return [] + # Fallback: scan filesystem for directories with build files + return _scan_filesystem_for_modules(directory) def _is_build_root(directory: Path) -> bool: diff --git a/tests/test_gradle_resolve_project_classpath.py b/tests/test_gradle_resolve_project_classpath.py new file mode 100644 index 000000000..0884b825c --- /dev/null +++ b/tests/test_gradle_resolve_project_classpath.py @@ -0,0 +1,210 @@ +"""Tests for GradleStrategy._resolve_project_classpath and _compile_dependency_modules.""" + +import os +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest + +from codeflash.languages.java.gradle_strategy import GradleStrategy + + +@pytest.fixture() +def strategy(): + return GradleStrategy() + + +@pytest.fixture() +def build_root(tmp_path): + """Create a multi-module Gradle project layout with missing JARs.""" + root = (tmp_path / "project").resolve() + root.mkdir() + + # Module A: compiled (has classes but no JAR) + mod_a = root / "module-a" + (mod_a / "build" / "classes" / "java" / "main" / "com" / "example").mkdir(parents=True) + (mod_a / "build" / "classes" / "java" / "main" / "com" / "example" / "A.class").write_bytes(b"") + (mod_a / "build" / "resources" / "main" / "META-INF").mkdir(parents=True) + (mod_a / "build" / "resources" / "main" / "META-INF" / "services.txt").write_bytes(b"") + + # Module B: compiled with Kotlin (has kotlin classes but no JAR) + mod_b = root / "module-b" + (mod_b / "build" / "classes" / "kotlin" / "main" / "com" / "example").mkdir(parents=True) + (mod_b / "build" / "classes" / "kotlin" / "main" / "com" / "example" / "B.class").write_bytes(b"") + (mod_b / "build" / "classes" / "java" / "main" / "com" / "example").mkdir(parents=True) + (mod_b / "build" / "classes" / "java" / "main" / "com" / "example" / "BHelper.class").write_bytes(b"") + + # Module C: uncompiled (no build/classes at all — testRuntimeOnly dep) + mod_c = root / "module-c" + mod_c.mkdir() + + # External dependency JAR (exists) + ext_dir = tmp_path / "gradle-cache" + ext_dir.mkdir() + ext_jar = ext_dir / "some-lib-1.0.jar" + ext_jar.write_bytes(b"") + + return root + + +def _make_classpath(build_root: Path, tmp_path: Path) -> str: + """Build a classpath string mimicking Gradle's testRuntimeClasspath output.""" + sep = os.pathsep + ext_jar = str(tmp_path / "gradle-cache" / "some-lib-1.0.jar") + return sep.join([ + str(build_root / "module-a" / "build" / "libs" / "module-a-1.0.jar"), + ext_jar, + str(build_root / "module-b" / "build" / "libs" / "module-b-1.0.jar"), + str(build_root / "module-c" / "build" / "libs" / "module-c-1.0.jar"), + ]) + + +def test_replaces_missing_jars_with_class_dirs(strategy, build_root, tmp_path): + """Missing project JARs should be replaced with class/resource directories.""" + classpath = _make_classpath(build_root, tmp_path) + + with ( + patch.object(GradleStrategy, "find_executable", return_value="gradle"), + patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout") as mock_run, + ): + # Mock the compilation of module-c + mock_run.return_value = subprocess.CompletedProcess(args=["gradle"], returncode=0, stdout="", stderr="") + # Simulate that compilation creates the class directory + (build_root / "module-c" / "build" / "classes" / "java" / "main").mkdir(parents=True) + + result = strategy._resolve_project_classpath(classpath, build_root, {}, timeout=60) + + entries = result.split(os.pathsep) + + ext_jar = str(tmp_path / "gradle-cache" / "some-lib-1.0.jar") + mod_a_java = str(build_root / "module-a" / "build" / "classes" / "java" / "main") + mod_a_resources = str(build_root / "module-a" / "build" / "resources" / "main") + mod_b_kotlin = str(build_root / "module-b" / "build" / "classes" / "kotlin" / "main") + mod_b_java = str(build_root / "module-b" / "build" / "classes" / "java" / "main") + mod_c_java = str(build_root / "module-c" / "build" / "classes" / "java" / "main") + + # Full equality: module-a JAR → java/main + resources/main, + # external JAR preserved, module-b JAR → kotlin/main + java/main, + # module-c JAR → java/main (compiled by mock) + assert entries == [ + mod_a_java, + mod_a_resources, + ext_jar, + mod_b_kotlin, + mod_b_java, + mod_c_java, + ] + + +def test_compiles_uncompiled_modules(strategy, build_root, tmp_path): + """Modules with no compiled classes should trigger a Gradle compilation.""" + classpath = _make_classpath(build_root, tmp_path) + + with ( + patch.object(GradleStrategy, "find_executable", return_value="gradle"), + patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout") as mock_run, + ): + mock_run.return_value = subprocess.CompletedProcess(args=["gradle"], returncode=0, stdout="", stderr="") + + strategy._resolve_project_classpath(classpath, build_root, {"JAVA_HOME": "/jdk"}, timeout=120) + + # Should have been called once to compile module-c + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert cmd[0] == "gradle" + assert ":module-c:classes" in cmd + assert "--no-daemon" in cmd + + +def test_no_compilation_when_all_compiled(strategy, build_root, tmp_path): + """When all modules have compiled classes, no compilation should be triggered.""" + # Give module-c some compiled classes + (build_root / "module-c" / "build" / "classes" / "java" / "main").mkdir(parents=True) + (build_root / "module-c" / "build" / "classes" / "java" / "main" / "C.class").write_bytes(b"") + + classpath = _make_classpath(build_root, tmp_path) + + with ( + patch.object(GradleStrategy, "find_executable", return_value="gradle"), + patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout") as mock_run, + ): + strategy._resolve_project_classpath(classpath, build_root, {}, timeout=60) + + # No Gradle call should have been made + mock_run.assert_not_called() + + +def test_noop_when_no_missing_jars(strategy, build_root, tmp_path): + """When all JARs exist, the classpath should be returned unchanged.""" + # Create all the JAR files + for mod in ["module-a", "module-b", "module-c"]: + jar_dir = build_root / mod / "build" / "libs" + jar_dir.mkdir(parents=True, exist_ok=True) + (jar_dir / f"{mod}-1.0.jar").write_bytes(b"") + + classpath = _make_classpath(build_root, tmp_path) + result = strategy._resolve_project_classpath(classpath, build_root, {}, timeout=60) + assert result == classpath + + +def test_external_missing_jar_preserved(strategy, tmp_path): + """Missing external JARs (not under build_root) should be kept as-is.""" + root = (tmp_path / "project").resolve() + root.mkdir() + + external_jar = "/some/external/path/lib.jar" + classpath = external_jar + + result = strategy._resolve_project_classpath(classpath, root, {}, timeout=60) + assert result == external_jar + + +def test_nested_gradle_module(strategy, tmp_path): + """Nested Gradle modules (connect/runtime) should be handled correctly.""" + root = (tmp_path / "project").resolve() + root.mkdir() + + # Nested module: connect/runtime + nested = root / "connect" / "runtime" + (nested / "build" / "classes" / "java" / "main").mkdir(parents=True) + (nested / "build" / "classes" / "java" / "main" / "R.class").write_bytes(b"") + + jar_path = str(root / "connect" / "runtime" / "build" / "libs" / "runtime-1.0.jar") + classpath = jar_path + + result = strategy._resolve_project_classpath(classpath, root, {}, timeout=60) + entries = result.split(os.pathsep) + + assert str(root / "connect" / "runtime" / "build" / "classes" / "java" / "main") in entries + assert jar_path not in entries + + +def test_compile_dependency_modules_single_call(strategy, tmp_path): + """Multiple uncompiled modules should be compiled in a single Gradle invocation.""" + root = (tmp_path / "project").resolve() + root.mkdir() + + with ( + patch.object(GradleStrategy, "find_executable", return_value="gradle"), + patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout") as mock_run, + ): + mock_run.return_value = subprocess.CompletedProcess(args=["gradle"], returncode=0, stdout="", stderr="") + + strategy._compile_dependency_modules(root, {}, ["module-a", "module-b", "connect:runtime"], timeout=120) + + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert ":module-a:classes" in cmd + assert ":module-b:classes" in cmd + assert ":connect:runtime:classes" in cmd + + +def test_compile_dependency_modules_gradle_not_found(strategy, tmp_path): + """Should not crash when Gradle executable is not found.""" + root = (tmp_path / "project").resolve() + root.mkdir() + + with patch.object(GradleStrategy, "find_executable", return_value=None): + # Should not raise + strategy._compile_dependency_modules(root, {}, ["module-a"], timeout=60) diff --git a/tests/test_languages/test_java/test_java_test_paths.py b/tests/test_languages/test_java/test_java_test_paths.py index 3a6ff95db..766a83b58 100644 --- a/tests/test_languages/test_java/test_java_test_paths.py +++ b/tests/test_languages/test_java/test_java_test_paths.py @@ -489,23 +489,42 @@ class TestExtractModulesFromSettingsGradle: def test_simple_top_level_modules(self): content = """include("streams", "clients", "tools")""" modules = _extract_modules_from_settings_gradle(content) - assert "streams" in modules - assert "clients" in modules - assert "tools" in modules + assert modules == ["streams", "clients", "tools"] def test_nested_gradle_modules(self): """Nested modules like connect:runtime should be extracted.""" content = """include("connect:runtime", "connect:api", "streams")""" modules = _extract_modules_from_settings_gradle(content) - assert "connect:runtime" in modules - assert "connect:api" in modules - assert "streams" in modules + assert modules == ["connect:runtime", "connect:api", "streams"] def test_leading_colon_stripped(self): content = """include(":streams", ":clients")""" modules = _extract_modules_from_settings_gradle(content) - assert "streams" in modules - assert "clients" in modules + assert modules == ["streams", "clients"] + + def test_multiline_include(self): + """Multi-line include() calls should be parsed correctly.""" + content = 'include(\n "rewrite-core",\n "rewrite-java",\n "rewrite-test"\n)' + modules = _extract_modules_from_settings_gradle(content) + assert modules == ["rewrite-core", "rewrite-java", "rewrite-test"] + + def test_kotlin_listof_variable(self): + """Kotlin-style val x = listOf(...) should be parsed for module names.""" + content = 'val allProjects = listOf(\n "rewrite-core",\n "rewrite-java",\n "rewrite-test"\n)\ninclude(*(allProjects).toTypedArray())' + modules = _extract_modules_from_settings_gradle(content) + assert modules == ["rewrite-core", "rewrite-java", "rewrite-test"] + + def test_groovy_include_without_parens(self): + """Groovy-style include without parentheses.""" + content = "include 'streams', 'clients'" + modules = _extract_modules_from_settings_gradle(content) + assert modules == ["streams", "clients"] + + def test_deduplicates_modules(self): + """Same module referenced in listOf and include should appear once.""" + content = 'val mods = listOf("core")\ninclude("core", "web")' + modules = _extract_modules_from_settings_gradle(content) + assert modules == ["core", "web"] class TestFindMultiModuleRoot: