fix: use JaCoCo agent instead of Gradle plugin for coverage collection

The previous approach used a Gradle init script to inject the JaCoCo plugin
and ran jacocoTestReport as a Gradle task. On large multi-module projects
(e.g., eureka), this added 5-10 minutes of Gradle overhead for report
generation, causing persistent timeouts.

Switch to the JaCoCo Java agent approach:
- Inject -javaagent:jacocoagent.jar via JAVA_TOOL_OPTIONS env var
- Coverage data collected during test execution with near-zero overhead
- Convert .exec to .xml using JaCoCo CLI after tests complete (~2 seconds)
- No Gradle plugin, init script, or jacocoTestReport task needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mohamed Ashraf 2026-04-07 01:25:48 +00:00
parent 933ae26719
commit 7c9ddf0dbc
3 changed files with 163 additions and 91 deletions

View file

@ -78,18 +78,8 @@ def _get_skip_validation_init_script() -> str:
return _skip_validation_init_path
# Lazily-created temp file for the JaCoCo init script.
_jacoco_init_path: str | None = None
def _get_jacoco_init_script() -> str:
global _jacoco_init_path
if _jacoco_init_path is None or not Path(_jacoco_init_path).exists():
fd, path = tempfile.mkstemp(suffix=".gradle", prefix="codeflash_jacoco_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(_JACOCO_INIT_SCRIPT)
_jacoco_init_path = path
return _jacoco_init_path
# JaCoCo version used for agent and CLI JARs.
_JACOCO_VERSION = "0.8.11"
# Cache for classpath strings — keyed on (gradle_root, test_module).
@ -117,24 +107,6 @@ gradle.projectsEvaluated {
}
"""
# Gradle init script that applies JaCoCo plugin for coverage collection.
# Uses projectsEvaluated + JavaPlugin guard so it only applies to subprojects
# that actually compile Java (skips container/root projects without java plugin).
_JACOCO_INIT_SCRIPT = """\
gradle.projectsEvaluated {
allprojects { project ->
project.plugins.withType(JavaPlugin) {
project.apply plugin: 'jacoco'
project.jacocoTestReport {
reports {
xml.required = true
html.required = false
}
}
}
}
}
"""
def find_gradle_build_file(project_root: Path) -> Path | None:
@ -749,14 +721,6 @@ class GradleStrategy(BuildToolStrategy):
cmd = [gradle, task, "--no-daemon", "--rerun", "--init-script", init_path]
cmd.extend(["--init-script", _get_skip_validation_init_script()])
if enable_coverage:
jacoco_init = _get_jacoco_init_script()
cmd.extend(["--init-script", jacoco_init])
# --continue ensures Gradle keeps going even if some tests fail,
# so jacocoTestReport runs even after test failures
# (matches Maven's -Dmaven.test.failure.ignore=true).
cmd.append("--continue")
# Note: multi-module --tests filtering is handled by
# filter.failOnNoMatchingTests = false in the init script above
# (matches Maven's -DfailIfNoTests=false).
@ -766,12 +730,8 @@ class GradleStrategy(BuildToolStrategy):
cmd.extend(["--tests", class_filter])
logger.debug("Added --tests filters to Gradle command")
# Append jacocoTestReport AFTER --tests so Gradle doesn't try to apply --tests to it.
# Must be module-qualified for multi-module projects — running it at root level fails
# if the root project doesn't have the java plugin (e.g., eureka).
if enable_coverage:
jacoco_task = f":{test_module}:jacocoTestReport" if test_module else "jacocoTestReport"
cmd.append(jacoco_task)
# JaCoCo coverage is collected via -javaagent (set up by run_tests_with_coverage),
# not via a Gradle task. No extra tasks or init scripts needed here.
logger.debug("Running Gradle command: %s in %s", " ".join(cmd), build_root)
@ -911,31 +871,60 @@ class GradleStrategy(BuildToolStrategy):
) -> tuple[subprocess.CompletedProcess[str], Path, Path | None]:
from codeflash.languages.java.test_runner import _get_combined_junit_xml
coverage_xml_path = self.setup_coverage(build_root, test_module, build_root)
# Determine coverage paths
if test_module:
module_path = build_root / module_to_dir(test_module)
else:
module_path = build_root
exec_path = module_path / "build" / "jacoco" / "test.exec"
exec_path.parent.mkdir(parents=True, exist_ok=True)
xml_path = exec_path.with_suffix(".xml")
# Inject JaCoCo agent via JAVA_TOOL_OPTIONS — collects coverage during test execution
# without requiring any Gradle plugin or jacocoTestReport task.
try:
agent_jar = get_jacoco_agent_jar()
agent_opts = f"-javaagent:{agent_jar.absolute()}=destfile={exec_path.absolute()}"
existing = run_env.get("JAVA_TOOL_OPTIONS", "")
run_env["JAVA_TOOL_OPTIONS"] = f"{existing} {agent_opts}".strip() if existing else agent_opts
logger.info("JaCoCo agent enabled: %s", agent_opts)
except Exception:
logger.exception("Failed to configure JaCoCo agent — coverage will be unavailable")
xml_path = None
result = self.run_tests_via_build_tool(
build_root,
test_paths,
run_env,
timeout=timeout,
mode="behavior",
enable_coverage=True,
test_module=test_module,
build_root, test_paths, run_env, timeout=timeout, mode="behavior",
enable_coverage=True, test_module=test_module,
)
# Convert .exec → .xml using JaCoCo CLI (fast, ~2s even on large projects)
if xml_path and exec_path.exists():
classes_dirs = [
module_path / "build" / "classes" / "java" / "main",
module_path / "build" / "classes" / "java" / "test",
]
sources_dirs = [
module_path / "src" / "main" / "java",
module_path / "src" / "test" / "java",
]
convert_jacoco_exec_to_xml(exec_path, classes_dirs, sources_dirs, xml_path)
elif xml_path:
logger.warning("JaCoCo .exec not found at %s — agent may not have run", exec_path)
xml_path = None
reports_dir = self.get_reports_dir(build_root, test_module)
result_xml_path = _get_combined_junit_xml(reports_dir, candidate_index)
return result, result_xml_path, coverage_xml_path
return result, result_xml_path, xml_path
def setup_coverage(self, build_root: Path, test_module: str | None, project_root: Path) -> Path | None:
# JaCoCo plugin is applied via init script (_JACOCO_INIT_SCRIPT) at test execution time,
# so no build file modification is needed here. Just return the expected report path.
# Coverage is collected via JaCoCo agent (injected in run_tests_with_coverage).
# Return the expected XML path so callers know where to look.
if test_module:
module_root = build_root / module_to_dir(test_module)
else:
module_root = project_root
return module_root / "build" / "reports" / "jacoco" / "test" / "jacocoTestReport.xml"
return module_root / "build" / "jacoco" / "test.xml"
def get_test_run_command(self, project_root: Path, test_classes: list[str] | None = None) -> list[str]:
from codeflash.languages.java.test_runner import _validate_java_class_name
@ -952,3 +941,88 @@ class GradleStrategy(BuildToolStrategy):
for cls in test_classes:
cmd.extend(["--tests", cls])
return cmd
def get_jacoco_agent_jar(codeflash_home: Path | None = None) -> Path:
if codeflash_home is None:
codeflash_home = Path.home() / ".codeflash"
agent_dir = codeflash_home / "java_agents"
agent_dir.mkdir(parents=True, exist_ok=True)
agent_jar = agent_dir / "jacocoagent.jar"
if not agent_jar.exists():
import urllib.request
url = (
f"https://repo1.maven.org/maven2/org/jacoco/org.jacoco.agent/{_JACOCO_VERSION}/"
f"org.jacoco.agent-{_JACOCO_VERSION}-runtime.jar"
)
logger.info("Downloading JaCoCo agent from %s", url)
urllib.request.urlretrieve(url, agent_jar) # noqa: S310
logger.info("Downloaded JaCoCo agent to %s", agent_jar)
return agent_jar
def get_jacoco_cli_jar(codeflash_home: Path | None = None) -> Path:
if codeflash_home is None:
codeflash_home = Path.home() / ".codeflash"
cli_dir = codeflash_home / "java_agents"
cli_dir.mkdir(parents=True, exist_ok=True)
cli_jar = cli_dir / "jacococli.jar"
if not cli_jar.exists():
import urllib.request
url = (
f"https://repo1.maven.org/maven2/org/jacoco/org.jacoco.cli/{_JACOCO_VERSION}/"
f"org.jacoco.cli-{_JACOCO_VERSION}-nodeps.jar"
)
logger.info("Downloading JaCoCo CLI from %s", url)
urllib.request.urlretrieve(url, cli_jar) # noqa: S310
logger.info("Downloaded JaCoCo CLI to %s", cli_jar)
return cli_jar
def convert_jacoco_exec_to_xml(
exec_path: Path,
classes_dirs: list[Path],
sources_dirs: list[Path],
xml_path: Path,
) -> bool:
if not exec_path.exists():
logger.error("JaCoCo .exec file not found: %s", exec_path)
return False
try:
cli_jar = get_jacoco_cli_jar()
except Exception:
logger.exception("Failed to get JaCoCo CLI")
return False
cmd: list[str] = ["java", "-jar", str(cli_jar), "report", str(exec_path)]
for d in classes_dirs:
if d.exists():
cmd.extend(["--classfiles", str(d)])
for d in sources_dirs:
if d.exists():
cmd.extend(["--sourcefiles", str(d)])
cmd.extend(["--xml", str(xml_path)])
logger.info("Converting JaCoCo .exec to XML: %s", " ".join(cmd))
try:
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
if result.returncode == 0:
logger.info("JaCoCo coverage XML generated: %s", xml_path)
return True
logger.error("Failed to convert .exec to XML: %s", result.stderr)
return False
except subprocess.TimeoutExpired:
logger.exception("JaCoCo CLI conversion timed out")
return False
except Exception:
logger.exception("Error converting .exec to XML")
return False

View file

@ -423,14 +423,13 @@ def run_behavioral_tests(
if enable_coverage:
coverage_xml_path = strategy.setup_coverage(build_root, test_module, project_root)
# Coverage runs add JaCoCo overhead (instrumentation + report generation) on top of
# normal test execution. Gradle --no-daemon on large projects (e.g., eureka-core)
# needs ~10min for cold startup + compilation + tests, plus ~3min for JaCoCo report.
min_timeout = 900 if enable_coverage else 60
# Coverage runs use the build tool (not direct JVM), so Gradle --no-daemon cold startup +
# compilation + test execution can take 5+ min on large multi-module projects.
min_timeout = 600 if enable_coverage else 60
effective_timeout = max(timeout or 300, min_timeout)
if enable_coverage:
# Coverage MUST use build tool — JaCoCo runs as a plugin during the verify phase
# Coverage uses the build tool with JaCoCo agent injected via JAVA_TOOL_OPTIONS
result, result_xml_path, coverage_xml_path = strategy.run_tests_with_coverage(
build_root, test_module, test_paths, run_env, effective_timeout, candidate_index
)
@ -458,20 +457,13 @@ def run_behavioral_tests(
)
if enable_coverage and coverage_xml_path:
target_dir = strategy.get_build_output_dir(build_root, test_module)
jacoco_exec_path = target_dir / "jacoco.exec"
logger.info("Coverage paths - target_dir: %s, coverage_xml_path: %s", target_dir, coverage_xml_path)
if jacoco_exec_path.exists():
logger.info("JaCoCo exec file exists: %s (%s bytes)", jacoco_exec_path, jacoco_exec_path.stat().st_size)
else:
logger.warning("JaCoCo exec file not found: %s - JaCoCo agent may not have run", jacoco_exec_path)
if coverage_xml_path.exists():
file_size = coverage_xml_path.stat().st_size
logger.info("JaCoCo XML report exists: %s (%s bytes)", coverage_xml_path, file_size)
logger.info("JaCoCo coverage XML: %s (%s bytes)", coverage_xml_path, file_size)
if file_size == 0:
logger.warning("JaCoCo XML report is empty - report generation may have failed")
logger.warning("JaCoCo XML report is empty — report generation may have failed")
else:
logger.warning("JaCoCo XML report not found: %s - verify phase may not have completed", coverage_xml_path)
logger.warning("JaCoCo XML report not found: %s", coverage_xml_path)
return result_xml_path, result, coverage_xml_path, None

View file

@ -759,20 +759,20 @@ class TestGradleEnsureRuntimeFallback:
class TestGradleSetupCoverage:
"""Tests for GradleStrategy.setup_coverage — returns report path without modifying build files."""
"""Tests for GradleStrategy.setup_coverage — returns expected XML path for JaCoCo agent output."""
def test_returns_report_path_for_module(self, tmp_path):
strategy = GradleStrategy()
path = strategy.setup_coverage(tmp_path, test_module="eureka-core", project_root=tmp_path)
assert path == tmp_path / "eureka-core" / "build" / "reports" / "jacoco" / "test" / "jacocoTestReport.xml"
assert path == tmp_path / "eureka-core" / "build" / "jacoco" / "test.xml"
def test_returns_report_path_without_module(self, tmp_path):
strategy = GradleStrategy()
path = strategy.setup_coverage(tmp_path, test_module=None, project_root=tmp_path)
assert path == tmp_path / "build" / "reports" / "jacoco" / "test" / "jacocoTestReport.xml"
assert path == tmp_path / "build" / "jacoco" / "test.xml"
def test_does_not_modify_build_files(self, tmp_path):
"""setup_coverage must NOT modify build.gradle — JaCoCo is applied via init script."""
"""setup_coverage must NOT modify build.gradle — coverage is collected via JaCoCo agent."""
build_file = tmp_path / "build.gradle"
original_content = "plugins { id 'java' }\n"
build_file.write_text(original_content, encoding="utf-8")
@ -782,25 +782,31 @@ class TestGradleSetupCoverage:
assert build_file.read_text(encoding="utf-8") == original_content
class TestGradleJacocoInitScript:
"""Tests for the JaCoCo init script content and helper."""
class TestJacocoAgentDownload:
"""Tests for JaCoCo agent and CLI JAR download helpers."""
def test_init_script_has_java_plugin_guard(self):
from codeflash.languages.java.gradle_strategy import _JACOCO_INIT_SCRIPT
def test_get_jacoco_agent_jar_downloads_to_codeflash_home(self, tmp_path):
from codeflash.languages.java.gradle_strategy import get_jacoco_agent_jar
assert "withType(JavaPlugin)" in _JACOCO_INIT_SCRIPT
jar = get_jacoco_agent_jar(codeflash_home=tmp_path)
assert jar == tmp_path / "java_agents" / "jacocoagent.jar"
# JAR is downloaded from Maven Central — verify it exists and is non-empty
assert jar.exists()
assert jar.stat().st_size > 0
def test_get_jacoco_init_script_creates_temp_file(self):
from codeflash.languages.java.gradle_strategy import _JACOCO_INIT_SCRIPT, _get_jacoco_init_script
def test_get_jacoco_agent_jar_is_cached(self, tmp_path):
from codeflash.languages.java.gradle_strategy import get_jacoco_agent_jar
path = _get_jacoco_init_script()
assert Path(path).exists()
content = Path(path).read_text(encoding="utf-8")
assert content == _JACOCO_INIT_SCRIPT
jar1 = get_jacoco_agent_jar(codeflash_home=tmp_path)
size1 = jar1.stat().st_size
jar2 = get_jacoco_agent_jar(codeflash_home=tmp_path)
assert jar1 == jar2
assert jar2.stat().st_size == size1
def test_get_jacoco_init_script_is_cached(self):
from codeflash.languages.java.gradle_strategy import _get_jacoco_init_script
def test_get_jacoco_cli_jar_downloads(self, tmp_path):
from codeflash.languages.java.gradle_strategy import get_jacoco_cli_jar
path1 = _get_jacoco_init_script()
path2 = _get_jacoco_init_script()
assert path1 == path2
jar = get_jacoco_cli_jar(codeflash_home=tmp_path)
assert jar == tmp_path / "java_agents" / "jacococli.jar"
assert jar.exists()
assert jar.stat().st_size > 0