Skip to content

Commit

Permalink
Fix regression on Jacoco coverage & Corbertura (#264)
Browse files Browse the repository at this point in the history
* Fix jacoco coverage to use mi, nr

* Corbertura merge coverage by file names that are reported separately

* lower vaniala js to 60

* bump version
  • Loading branch information
coderustic authored Jan 12, 2025
1 parent 1b1a9ad commit f46980f
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 22 deletions.
79 changes: 65 additions & 14 deletions cover_agent/coverage/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,21 @@ def parse_coverage_report(self) -> Dict[str, CoverageData]:
for cls in root.findall(".//class"):
cls_filename = cls.get("filename")
if cls_filename:
coverage[cls_filename] = self._parse_coverage_data_for_class(cls)
if cls_filename not in coverage:
coverage[cls_filename] = self._parse_coverage_data_for_class(cls)
else:
coverage[cls_filename] = self._merge_coverage_data(coverage[cls_filename], self._parse_coverage_data_for_class(cls))
return coverage

def _merge_coverage_data(self, existing_coverage: CoverageData, new_coverage: CoverageData) -> CoverageData:
covered_lines = existing_coverage.covered_lines + new_coverage.covered_lines
missed_lines = existing_coverage.missed_lines + new_coverage.missed_lines
covered = existing_coverage.covered + new_coverage.covered
missed = existing_coverage.missed + new_coverage.missed
total_lines = covered + missed
coverage_percentage = (float(covered) / total_lines) if total_lines > 0 else 0.0
return CoverageData(covered_lines, covered, missed_lines, missed, coverage_percentage)

def _parse_coverage_data_for_class(self, cls) -> CoverageData:
lines_covered, lines_missed = [], []
for line in cls.findall(".//line"):
Expand Down Expand Up @@ -223,22 +235,58 @@ class JacocoProcessor(CoverageProcessor):
"""
def parse_coverage_report(self) -> Dict[str, CoverageData]:
coverage = {}
package_name, class_name = self._extract_package_and_class_java()
source_file_extension = self._get_file_extension(self.src_file_path)

package_name, class_name = "",""
if source_file_extension == 'java':
package_name, class_name = self._extract_package_and_class_java()
elif source_file_extension == 'kt':
package_name, class_name = self._extract_package_and_class_kotlin()
else:
self.logger.warn(f"Unsupported Bytecode Language: {source_file_extension}. Using default Java logic.")
package_name, class_name = self.extract_package_and_class_java()

file_extension = self._get_file_extension(self.file_path)

if file_extension == 'xml':
missed, covered = self._parse_jacoco_xml(class_name=class_name)
lines_missed, lines_covered = self._parse_jacoco_xml(class_name=class_name)
missed, covered = len(lines_missed), len(lines_covered)
elif file_extension == 'csv':
lines_missed, lines_covered = [], []
missed, covered = self._parse_jacoco_csv(package_name=package_name, class_name=class_name)
else:
raise ValueError(f"Unsupported JaCoCo code coverage report format: {file_extension}")
total_lines = missed + covered
coverage_percentage = (float(covered) / total_lines) if total_lines > 0 else 0.0
coverage[class_name] = CoverageData(covered_lines=[], covered=covered, missed_lines=[], missed=missed, coverage=coverage_percentage)
coverage[class_name] = CoverageData(covered_lines=lines_covered, covered=covered, missed_lines=lines_missed, missed=missed, coverage=coverage_percentage)
return coverage

def _get_file_extension(self, filename: str) -> str | None:
"""Get the file extension from a given filename."""
return os.path.splitext(filename)[1].lstrip(".")

def _extract_package_and_class_kotlin(self):
package_pattern = re.compile(r"^\s*package\s+([\w.]+)\s*(?:;)?\s*(?://.*)?$")
class_pattern = re.compile(r"^\s*(?:public|internal|abstract|data|sealed|enum|open|final|private|protected)*\s*class\s+(\w+).*")
package_name = ""
class_name = ""
try:
with open(self.src_file_path, "r") as file:
for line in file:
if not package_name: # Only match package if not already found
package_match = package_pattern.match(line)
if package_match:
package_name = package_match.group(1)
if not class_name: # Only match class if not already found
class_match = class_pattern.match(line)
if class_match:
class_name = class_match.group(1)
if package_name and class_name: # Exit loop if both are found
break
except (FileNotFoundError, IOError) as e:
self.logger.error(f"Error reading file {self.src_file_path}: {e}")
raise
return package_name, class_name

def _extract_package_and_class_java(self):
package_pattern = re.compile(r"^\s*package\s+([\w\.]+)\s*;.*$")
Expand Down Expand Up @@ -269,21 +317,24 @@ def _extract_package_and_class_java(self):

def _parse_jacoco_xml(
self, class_name: str
) -> tuple[int, int]:
) -> tuple[list, list]:
"""Parses a JaCoCo XML code coverage report to extract covered and missed line numbers for a specific file."""
tree = ET.parse(self.file_path)
root = tree.getroot()
sourcefile = root.find(f".//sourcefile[@name='{class_name}.java']")
sourcefile = (
root.find(f".//sourcefile[@name='{class_name}.java']") or
root.find(f".//sourcefile[@name='{class_name}.kt']")
)

if sourcefile is None:
return 0, 0

missed, covered = 0, 0
for counter in sourcefile.findall('counter'):
if counter.attrib.get('type') == 'LINE':
missed += int(counter.attrib.get('missed', 0))
covered += int(counter.attrib.get('covered', 0))
break
return [], []

missed, covered = [], []
for line in sourcefile.findall('line'):
if line.attrib.get('mi') == '0':
covered += [int(line.attrib.get('nr', 0))]
else :
missed += [int(line.attrib.get('nr', 0))]

return missed, covered
def _parse_jacoco_csv(self, package_name, class_name) -> Dict[str, CoverageData]:
Expand Down
2 changes: 1 addition & 1 deletion cover_agent/version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.15
0.2.16
162 changes: 155 additions & 7 deletions tests/coverage/test_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ def mock_parse(file_path):
<line number="2" hits="0"/>
</lines>
</class>
<class filename="app.py">
<lines>
<line number="3" hits="1"/>
<line number="4" hits="0"/>
</lines>
</class>
</classes>
</package>
</packages>
Expand Down Expand Up @@ -189,10 +195,10 @@ def test_parse_coverage_report_cobertura(self, mock_xml_tree, processor):
"""
coverage = processor.parse_coverage_report()
assert len(coverage) == 1, "Expected coverage data for one file"
assert coverage["app.py"].covered_lines == [1], "Should list line 1 as covered"
assert coverage["app.py"].covered == 1, "Should have 1 line as covered"
assert coverage["app.py"].missed_lines == [2], "Should list line 2 as missed"
assert coverage["app.py"].missed == 1, "Should have 1 line as missed"
assert coverage["app.py"].covered_lines == [1, 3], "Should list lines 1 and 3 as covered"
assert coverage["app.py"].covered == 2, "Should have 2 line as covered"
assert coverage["app.py"].missed_lines == [2, 4], "Should list lines 2 and 4 as missed"
assert coverage["app.py"].missed == 2, "Should have 2 line as missed"
assert coverage["app.py"].coverage == 0.5, "Coverage should be 50 percent"

class TestLcovProcessor:
Expand Down Expand Up @@ -272,9 +278,10 @@ def test_parse_xml_coverage_report_success(self, mocker):
# Assert
assert len(coverage_data) == 1
assert 'MyClass' in coverage_data
assert coverage_data['MyClass'].missed == 5
assert coverage_data['MyClass'].covered == 15
assert coverage_data['MyClass'].coverage == 0.75
# should not include <counter type="LINE" missed="5" covered="15"/>
assert coverage_data['MyClass'].missed == 0
assert coverage_data['MyClass'].covered == 0
assert coverage_data['MyClass'].coverage == 0

# Handle empty or malformed XML/CSV coverage reports
def test_parse_empty_xml_coverage_report(self, mocker):
Expand All @@ -301,6 +308,147 @@ def test_parse_empty_xml_coverage_report(self, mocker):
assert coverage_data['MyClass'].covered == 0
assert coverage_data['MyClass'].coverage == 0.0

def test_returns_empty_lists_and_float(self, mocker):
# Mocking the necessary methods
mocker.patch(
"cover_agent.coverage.processor.JacocoProcessor._extract_package_and_class_java",
return_value=("com.example", "Example"),
)
mocker.patch(
"cover_agent.coverage.processor.JacocoProcessor._parse_jacoco_xml",
return_value=([], []),
)

# Initialize the CoverageProcessor object
coverage_processor = JacocoProcessor(
file_path="path/to/coverage.xml",
src_file_path="path/to/example.java",
)

# Invoke the parse_coverage_report_jacoco method
coverageData = coverage_processor.parse_coverage_report()

# Assert the results
assert coverageData["Example"].covered_lines == [], "Expected covered_lines to be an empty list"
assert coverageData["Example"].missed_lines == [], "Expected missed_lines to be an empty list"
assert coverageData["Example"].coverage == 0, "Expected coverage percentage to be 0"

def test_parse_missed_covered_lines_jacoco_xml_no_source_file(self, mocker):
#, mock_xml_tree
mocker.patch(
"cover_agent.coverage.processor.JacocoProcessor._extract_package_and_class_java",
return_value=("com.example", "MyClass"),
)
xml_str = """<?xml version="1.0" encoding="UTF-8"?>
<report>
<package name="path/to">
<sourcefile name="MyClass.java">
<line nr="35" mi="0" ci="9" mb="0" cb="0"/>
<line nr="36" mi="0" ci="1" mb="0" cb="0"/>
<line nr="37" mi="0" ci="3" mb="0" cb="0"/>
<line nr="38" mi="0" ci="9" mb="0" cb="0"/>
<line nr="39" mi="1" ci="0" mb="0" cb="0"/>
<line nr="40" mi="5" ci="0" mb="0" cb="0"/>
<line nr="41" mi="9" ci="0" mb="0" cb="0"/>
<counter type="INSTRUCTION" missed="53" covered="387"/>
<counter type="BRANCH" missed="2" covered="6"/>
<counter type="LINE" missed="9" covered="94"/>
<counter type="COMPLEXITY" missed="5" covered="23"/>
<counter type="METHOD" missed="3" covered="21"/>
<counter type="CLASS" missed="0" covered="1"/>
</sourcefile>
</package>
</report>"""
mocker.patch(
"xml.etree.ElementTree.parse",
return_value=ET.ElementTree(ET.fromstring(xml_str))
)
processor = JacocoProcessor("path/to/coverage_report.xml", "path/to/MySecondClass.java")

# Action
coverage_data = processor.parse_coverage_report()

# Assert
assert 'MySecondClass' not in coverage_data

def test_parse_missed_covered_lines_jacoco_xml(self, mocker):
#, mock_xml_tree
mocker.patch(
"cover_agent.coverage.processor.JacocoProcessor._extract_package_and_class_java",
return_value=("com.example", "MyClass"),
)
xml_str = """<report>
<package name="path/to">
<sourcefile name="MyClass.java">
<line nr="35" mi="0" ci="9" mb="0" cb="0"/>
<line nr="36" mi="0" ci="1" mb="0" cb="0"/>
<line nr="37" mi="0" ci="3" mb="0" cb="0"/>
<line nr="38" mi="0" ci="9" mb="0" cb="0"/>
<line nr="39" mi="1" ci="0" mb="0" cb="0"/>
<line nr="40" mi="5" ci="0" mb="0" cb="0"/>
<line nr="41" mi="9" ci="0" mb="0" cb="0"/>
<counter type="INSTRUCTION" missed="53" covered="387"/>
<counter type="BRANCH" missed="2" covered="6"/>
<counter type="LINE" missed="9" covered="94"/>
<counter type="COMPLEXITY" missed="5" covered="23"/>
<counter type="METHOD" missed="3" covered="21"/>
<counter type="CLASS" missed="0" covered="1"/>
</sourcefile>
</package>
</report>"""
mocker.patch(
"xml.etree.ElementTree.parse",
return_value=ET.ElementTree(ET.fromstring(xml_str))
)
processor = JacocoProcessor("path/to/coverage_report.xml", "path/to/MyClass.java")

# Action
coverage_data = processor.parse_coverage_report()

# Assert
assert "MyClass" in coverage_data
assert coverage_data["MyClass"].missed_lines == [39, 40, 41]
assert coverage_data["MyClass"].covered_lines == [35, 36, 37, 38]

def test_parse_missed_covered_lines_kotlin_jacoco_xml(self, mocker):
#, mock_xml_tree
mocker.patch(
"cover_agent.coverage.processor.JacocoProcessor._extract_package_and_class_kotlin",
return_value=("com.example", "MyClass"),
)
xml_str = """<report>
<package name="path/to">
<sourcefile name="MyClass.kt">
<line nr="35" mi="0" ci="9" mb="0" cb="0"/>
<line nr="36" mi="0" ci="1" mb="0" cb="0"/>
<line nr="37" mi="0" ci="3" mb="0" cb="0"/>
<line nr="38" mi="0" ci="9" mb="0" cb="0"/>
<line nr="39" mi="1" ci="0" mb="0" cb="0"/>
<line nr="40" mi="5" ci="0" mb="0" cb="0"/>
<line nr="41" mi="9" ci="0" mb="0" cb="0"/>
<counter type="INSTRUCTION" missed="53" covered="387"/>
<counter type="BRANCH" missed="2" covered="6"/>
<counter type="LINE" missed="9" covered="94"/>
<counter type="COMPLEXITY" missed="5" covered="23"/>
<counter type="METHOD" missed="3" covered="21"/>
<counter type="CLASS" missed="0" covered="1"/>
</sourcefile>
</package>
</report>"""
mocker.patch(
"xml.etree.ElementTree.parse",
return_value=ET.ElementTree(ET.fromstring(xml_str))
)
processor = JacocoProcessor("path/to/coverage_report.xml", "path/to/MyClass.kt")

# Action
coverage_data = processor.parse_coverage_report()

# Assert
assert "MyClass" in coverage_data
assert coverage_data["MyClass"].missed_lines == [39, 40, 41]
assert coverage_data["MyClass"].covered_lines == [35, 36, 37, 38]

class TestDiffCoverageProcessor:
# Successfully parse JSON diff coverage report and extract coverage data for matching file path
def test_parse_coverage_report_with_matching_file(self, mocker):
Expand Down
1 change: 1 addition & 0 deletions tests_integration/test_all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ sh tests_integration/test_with_docker.sh \
--test-file-path "ui.test.js" \
--test-command "npm run test:coverage" \
--code-coverage-report-path "coverage/coverage.xml" \
--desired-coverage "60" \
--model $MODEL \
$log_db_arg

Expand Down

0 comments on commit f46980f

Please sign in to comment.