Skip to content

Commit f46980f

Browse files
authored
Fix regression on Jacoco coverage & Corbertura (#264)
* Fix jacoco coverage to use mi, nr * Corbertura merge coverage by file names that are reported separately * lower vaniala js to 60 * bump version
1 parent 1b1a9ad commit f46980f

File tree

4 files changed

+222
-22
lines changed

4 files changed

+222
-22
lines changed

cover_agent/coverage/processor.py

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,21 @@ def parse_coverage_report(self) -> Dict[str, CoverageData]:
163163
for cls in root.findall(".//class"):
164164
cls_filename = cls.get("filename")
165165
if cls_filename:
166-
coverage[cls_filename] = self._parse_coverage_data_for_class(cls)
166+
if cls_filename not in coverage:
167+
coverage[cls_filename] = self._parse_coverage_data_for_class(cls)
168+
else:
169+
coverage[cls_filename] = self._merge_coverage_data(coverage[cls_filename], self._parse_coverage_data_for_class(cls))
167170
return coverage
168171

172+
def _merge_coverage_data(self, existing_coverage: CoverageData, new_coverage: CoverageData) -> CoverageData:
173+
covered_lines = existing_coverage.covered_lines + new_coverage.covered_lines
174+
missed_lines = existing_coverage.missed_lines + new_coverage.missed_lines
175+
covered = existing_coverage.covered + new_coverage.covered
176+
missed = existing_coverage.missed + new_coverage.missed
177+
total_lines = covered + missed
178+
coverage_percentage = (float(covered) / total_lines) if total_lines > 0 else 0.0
179+
return CoverageData(covered_lines, covered, missed_lines, missed, coverage_percentage)
180+
169181
def _parse_coverage_data_for_class(self, cls) -> CoverageData:
170182
lines_covered, lines_missed = [], []
171183
for line in cls.findall(".//line"):
@@ -223,22 +235,58 @@ class JacocoProcessor(CoverageProcessor):
223235
"""
224236
def parse_coverage_report(self) -> Dict[str, CoverageData]:
225237
coverage = {}
226-
package_name, class_name = self._extract_package_and_class_java()
238+
source_file_extension = self._get_file_extension(self.src_file_path)
239+
240+
package_name, class_name = "",""
241+
if source_file_extension == 'java':
242+
package_name, class_name = self._extract_package_and_class_java()
243+
elif source_file_extension == 'kt':
244+
package_name, class_name = self._extract_package_and_class_kotlin()
245+
else:
246+
self.logger.warn(f"Unsupported Bytecode Language: {source_file_extension}. Using default Java logic.")
247+
package_name, class_name = self.extract_package_and_class_java()
248+
227249
file_extension = self._get_file_extension(self.file_path)
250+
228251
if file_extension == 'xml':
229-
missed, covered = self._parse_jacoco_xml(class_name=class_name)
252+
lines_missed, lines_covered = self._parse_jacoco_xml(class_name=class_name)
253+
missed, covered = len(lines_missed), len(lines_covered)
230254
elif file_extension == 'csv':
255+
lines_missed, lines_covered = [], []
231256
missed, covered = self._parse_jacoco_csv(package_name=package_name, class_name=class_name)
232257
else:
233258
raise ValueError(f"Unsupported JaCoCo code coverage report format: {file_extension}")
234259
total_lines = missed + covered
235260
coverage_percentage = (float(covered) / total_lines) if total_lines > 0 else 0.0
236-
coverage[class_name] = CoverageData(covered_lines=[], covered=covered, missed_lines=[], missed=missed, coverage=coverage_percentage)
261+
coverage[class_name] = CoverageData(covered_lines=lines_covered, covered=covered, missed_lines=lines_missed, missed=missed, coverage=coverage_percentage)
237262
return coverage
238263

239264
def _get_file_extension(self, filename: str) -> str | None:
240265
"""Get the file extension from a given filename."""
241266
return os.path.splitext(filename)[1].lstrip(".")
267+
268+
def _extract_package_and_class_kotlin(self):
269+
package_pattern = re.compile(r"^\s*package\s+([\w.]+)\s*(?:;)?\s*(?://.*)?$")
270+
class_pattern = re.compile(r"^\s*(?:public|internal|abstract|data|sealed|enum|open|final|private|protected)*\s*class\s+(\w+).*")
271+
package_name = ""
272+
class_name = ""
273+
try:
274+
with open(self.src_file_path, "r") as file:
275+
for line in file:
276+
if not package_name: # Only match package if not already found
277+
package_match = package_pattern.match(line)
278+
if package_match:
279+
package_name = package_match.group(1)
280+
if not class_name: # Only match class if not already found
281+
class_match = class_pattern.match(line)
282+
if class_match:
283+
class_name = class_match.group(1)
284+
if package_name and class_name: # Exit loop if both are found
285+
break
286+
except (FileNotFoundError, IOError) as e:
287+
self.logger.error(f"Error reading file {self.src_file_path}: {e}")
288+
raise
289+
return package_name, class_name
242290

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

270318
def _parse_jacoco_xml(
271319
self, class_name: str
272-
) -> tuple[int, int]:
320+
) -> tuple[list, list]:
273321
"""Parses a JaCoCo XML code coverage report to extract covered and missed line numbers for a specific file."""
274322
tree = ET.parse(self.file_path)
275323
root = tree.getroot()
276-
sourcefile = root.find(f".//sourcefile[@name='{class_name}.java']")
324+
sourcefile = (
325+
root.find(f".//sourcefile[@name='{class_name}.java']") or
326+
root.find(f".//sourcefile[@name='{class_name}.kt']")
327+
)
277328

278329
if sourcefile is None:
279-
return 0, 0
280-
281-
missed, covered = 0, 0
282-
for counter in sourcefile.findall('counter'):
283-
if counter.attrib.get('type') == 'LINE':
284-
missed += int(counter.attrib.get('missed', 0))
285-
covered += int(counter.attrib.get('covered', 0))
286-
break
330+
return [], []
331+
332+
missed, covered = [], []
333+
for line in sourcefile.findall('line'):
334+
if line.attrib.get('mi') == '0':
335+
covered += [int(line.attrib.get('nr', 0))]
336+
else :
337+
missed += [int(line.attrib.get('nr', 0))]
287338

288339
return missed, covered
289340
def _parse_jacoco_csv(self, package_name, class_name) -> Dict[str, CoverageData]:

cover_agent/version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.2.15
1+
0.2.16

tests/coverage/test_processor.py

Lines changed: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ def mock_parse(file_path):
8484
<line number="2" hits="0"/>
8585
</lines>
8686
</class>
87+
<class filename="app.py">
88+
<lines>
89+
<line number="3" hits="1"/>
90+
<line number="4" hits="0"/>
91+
</lines>
92+
</class>
8793
</classes>
8894
</package>
8995
</packages>
@@ -189,10 +195,10 @@ def test_parse_coverage_report_cobertura(self, mock_xml_tree, processor):
189195
"""
190196
coverage = processor.parse_coverage_report()
191197
assert len(coverage) == 1, "Expected coverage data for one file"
192-
assert coverage["app.py"].covered_lines == [1], "Should list line 1 as covered"
193-
assert coverage["app.py"].covered == 1, "Should have 1 line as covered"
194-
assert coverage["app.py"].missed_lines == [2], "Should list line 2 as missed"
195-
assert coverage["app.py"].missed == 1, "Should have 1 line as missed"
198+
assert coverage["app.py"].covered_lines == [1, 3], "Should list lines 1 and 3 as covered"
199+
assert coverage["app.py"].covered == 2, "Should have 2 line as covered"
200+
assert coverage["app.py"].missed_lines == [2, 4], "Should list lines 2 and 4 as missed"
201+
assert coverage["app.py"].missed == 2, "Should have 2 line as missed"
196202
assert coverage["app.py"].coverage == 0.5, "Coverage should be 50 percent"
197203

198204
class TestLcovProcessor:
@@ -272,9 +278,10 @@ def test_parse_xml_coverage_report_success(self, mocker):
272278
# Assert
273279
assert len(coverage_data) == 1
274280
assert 'MyClass' in coverage_data
275-
assert coverage_data['MyClass'].missed == 5
276-
assert coverage_data['MyClass'].covered == 15
277-
assert coverage_data['MyClass'].coverage == 0.75
281+
# should not include <counter type="LINE" missed="5" covered="15"/>
282+
assert coverage_data['MyClass'].missed == 0
283+
assert coverage_data['MyClass'].covered == 0
284+
assert coverage_data['MyClass'].coverage == 0
278285

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

311+
def test_returns_empty_lists_and_float(self, mocker):
312+
# Mocking the necessary methods
313+
mocker.patch(
314+
"cover_agent.coverage.processor.JacocoProcessor._extract_package_and_class_java",
315+
return_value=("com.example", "Example"),
316+
)
317+
mocker.patch(
318+
"cover_agent.coverage.processor.JacocoProcessor._parse_jacoco_xml",
319+
return_value=([], []),
320+
)
321+
322+
# Initialize the CoverageProcessor object
323+
coverage_processor = JacocoProcessor(
324+
file_path="path/to/coverage.xml",
325+
src_file_path="path/to/example.java",
326+
)
327+
328+
# Invoke the parse_coverage_report_jacoco method
329+
coverageData = coverage_processor.parse_coverage_report()
330+
331+
# Assert the results
332+
assert coverageData["Example"].covered_lines == [], "Expected covered_lines to be an empty list"
333+
assert coverageData["Example"].missed_lines == [], "Expected missed_lines to be an empty list"
334+
assert coverageData["Example"].coverage == 0, "Expected coverage percentage to be 0"
335+
336+
def test_parse_missed_covered_lines_jacoco_xml_no_source_file(self, mocker):
337+
#, mock_xml_tree
338+
mocker.patch(
339+
"cover_agent.coverage.processor.JacocoProcessor._extract_package_and_class_java",
340+
return_value=("com.example", "MyClass"),
341+
)
342+
xml_str = """<?xml version="1.0" encoding="UTF-8"?>
343+
<report>
344+
<package name="path/to">
345+
<sourcefile name="MyClass.java">
346+
<line nr="35" mi="0" ci="9" mb="0" cb="0"/>
347+
<line nr="36" mi="0" ci="1" mb="0" cb="0"/>
348+
<line nr="37" mi="0" ci="3" mb="0" cb="0"/>
349+
<line nr="38" mi="0" ci="9" mb="0" cb="0"/>
350+
<line nr="39" mi="1" ci="0" mb="0" cb="0"/>
351+
<line nr="40" mi="5" ci="0" mb="0" cb="0"/>
352+
<line nr="41" mi="9" ci="0" mb="0" cb="0"/>
353+
<counter type="INSTRUCTION" missed="53" covered="387"/>
354+
<counter type="BRANCH" missed="2" covered="6"/>
355+
<counter type="LINE" missed="9" covered="94"/>
356+
<counter type="COMPLEXITY" missed="5" covered="23"/>
357+
<counter type="METHOD" missed="3" covered="21"/>
358+
<counter type="CLASS" missed="0" covered="1"/>
359+
</sourcefile>
360+
</package>
361+
</report>"""
362+
mocker.patch(
363+
"xml.etree.ElementTree.parse",
364+
return_value=ET.ElementTree(ET.fromstring(xml_str))
365+
)
366+
processor = JacocoProcessor("path/to/coverage_report.xml", "path/to/MySecondClass.java")
367+
368+
# Action
369+
coverage_data = processor.parse_coverage_report()
370+
371+
# Assert
372+
assert 'MySecondClass' not in coverage_data
373+
374+
def test_parse_missed_covered_lines_jacoco_xml(self, mocker):
375+
#, mock_xml_tree
376+
mocker.patch(
377+
"cover_agent.coverage.processor.JacocoProcessor._extract_package_and_class_java",
378+
return_value=("com.example", "MyClass"),
379+
)
380+
xml_str = """<report>
381+
<package name="path/to">
382+
<sourcefile name="MyClass.java">
383+
<line nr="35" mi="0" ci="9" mb="0" cb="0"/>
384+
<line nr="36" mi="0" ci="1" mb="0" cb="0"/>
385+
<line nr="37" mi="0" ci="3" mb="0" cb="0"/>
386+
<line nr="38" mi="0" ci="9" mb="0" cb="0"/>
387+
<line nr="39" mi="1" ci="0" mb="0" cb="0"/>
388+
<line nr="40" mi="5" ci="0" mb="0" cb="0"/>
389+
<line nr="41" mi="9" ci="0" mb="0" cb="0"/>
390+
<counter type="INSTRUCTION" missed="53" covered="387"/>
391+
<counter type="BRANCH" missed="2" covered="6"/>
392+
<counter type="LINE" missed="9" covered="94"/>
393+
<counter type="COMPLEXITY" missed="5" covered="23"/>
394+
<counter type="METHOD" missed="3" covered="21"/>
395+
<counter type="CLASS" missed="0" covered="1"/>
396+
</sourcefile>
397+
</package>
398+
</report>"""
399+
mocker.patch(
400+
"xml.etree.ElementTree.parse",
401+
return_value=ET.ElementTree(ET.fromstring(xml_str))
402+
)
403+
processor = JacocoProcessor("path/to/coverage_report.xml", "path/to/MyClass.java")
404+
405+
# Action
406+
coverage_data = processor.parse_coverage_report()
407+
408+
# Assert
409+
assert "MyClass" in coverage_data
410+
assert coverage_data["MyClass"].missed_lines == [39, 40, 41]
411+
assert coverage_data["MyClass"].covered_lines == [35, 36, 37, 38]
412+
413+
def test_parse_missed_covered_lines_kotlin_jacoco_xml(self, mocker):
414+
#, mock_xml_tree
415+
mocker.patch(
416+
"cover_agent.coverage.processor.JacocoProcessor._extract_package_and_class_kotlin",
417+
return_value=("com.example", "MyClass"),
418+
)
419+
xml_str = """<report>
420+
<package name="path/to">
421+
<sourcefile name="MyClass.kt">
422+
<line nr="35" mi="0" ci="9" mb="0" cb="0"/>
423+
<line nr="36" mi="0" ci="1" mb="0" cb="0"/>
424+
<line nr="37" mi="0" ci="3" mb="0" cb="0"/>
425+
<line nr="38" mi="0" ci="9" mb="0" cb="0"/>
426+
<line nr="39" mi="1" ci="0" mb="0" cb="0"/>
427+
<line nr="40" mi="5" ci="0" mb="0" cb="0"/>
428+
<line nr="41" mi="9" ci="0" mb="0" cb="0"/>
429+
<counter type="INSTRUCTION" missed="53" covered="387"/>
430+
<counter type="BRANCH" missed="2" covered="6"/>
431+
<counter type="LINE" missed="9" covered="94"/>
432+
<counter type="COMPLEXITY" missed="5" covered="23"/>
433+
<counter type="METHOD" missed="3" covered="21"/>
434+
<counter type="CLASS" missed="0" covered="1"/>
435+
</sourcefile>
436+
</package>
437+
</report>"""
438+
mocker.patch(
439+
"xml.etree.ElementTree.parse",
440+
return_value=ET.ElementTree(ET.fromstring(xml_str))
441+
)
442+
processor = JacocoProcessor("path/to/coverage_report.xml", "path/to/MyClass.kt")
443+
444+
# Action
445+
coverage_data = processor.parse_coverage_report()
446+
447+
# Assert
448+
assert "MyClass" in coverage_data
449+
assert coverage_data["MyClass"].missed_lines == [39, 40, 41]
450+
assert coverage_data["MyClass"].covered_lines == [35, 36, 37, 38]
451+
304452
class TestDiffCoverageProcessor:
305453
# Successfully parse JSON diff coverage report and extract coverage data for matching file path
306454
def test_parse_coverage_report_with_matching_file(self, mocker):

tests_integration/test_all.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ sh tests_integration/test_with_docker.sh \
121121
--test-file-path "ui.test.js" \
122122
--test-command "npm run test:coverage" \
123123
--code-coverage-report-path "coverage/coverage.xml" \
124+
--desired-coverage "60" \
124125
--model $MODEL \
125126
$log_db_arg
126127

0 commit comments

Comments
 (0)