Skip to content

Commit 2f8064a

Browse files
authored
Merge pull request #111 from CatalystCode/thcao/android-code-coverage
Adding coverage report for Android
2 parents a4f82f1 + 2788751 commit 2f8064a

File tree

6 files changed

+310
-3
lines changed

6 files changed

+310
-3
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ React Native module to support Azure Notification Hub push notifications on Andr
66
![npm](https://img.shields.io/npm/dm/react-native-azurenotificationhub)
77
[![Build Status](https://dev.azure.com/phongthaicao/react-native-azurenotificationhub/_apis/build/status/CatalystCode.react-native-azurenotificationhub?branchName=master)](https://dev.azure.com/phongthaicao/react-native-azurenotificationhub/_apis/build/status/CatalystCode.react-native-azurenotificationhub?branchName=master)
88

9+
![Platform Android](https://img.shields.io/badge/-Android-blue)
910
![Platform iOS](https://img.shields.io/badge/-iOS-blue)
10-
![Azure DevOps coverage](https://img.shields.io/azure-devops/coverage/phongthaicao/react-native-azurenotificationhub/1)
11+
![Azure DevOps coverage (branch)](https://img.shields.io/azure-devops/coverage/phongthaicao/react-native-azurenotificationhub/1/master)
1112

1213
# Platform-specific Guides
1314
- [Android](docs/android-installation.md)

azure-pipelines.yml

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ steps:
2323
displayName: 'Install slather'
2424
workingDirectory: '../sample'
2525

26+
- script: dotnet tool install -g dotnet-reportgenerator-globaltool
27+
displayName: 'Install ReportGenerator'
28+
workingDirectory: '../sample'
29+
2630
- script: npm install
2731
displayName: 'Install dependencies'
2832
workingDirectory: '../sample'
@@ -55,8 +59,22 @@ steps:
5559
inputs:
5660
workingDirectory: '../sample/android'
5761
gradleWrapperFile: '../sample/android/gradlew'
62+
testResultsFiles: '../sample/android/app/build/test-results/testDebugUnitTest/TEST-com.reactnativeazurenotificationhubsample.ReactNativeNotificationHubModuleTest.xml'
63+
publishJUnitResults: 'true'
5864
tasks: 'test'
59-
continueOnError: false
65+
continueOnError: false
66+
67+
- task: Gradle@2
68+
displayName: 'Generate Android code coverage'
69+
inputs:
70+
workingDirectory: '../sample/android'
71+
gradleWrapperFile: '../sample/android/gradlew'
72+
tasks: 'clean createOfflineTestCoverageReport jacocoTestReport'
73+
continueOnError: false
74+
75+
- script: python ./cover2cover.py ./jacocoXml.xml>jacoco.xml
76+
displayName: 'Convert Jacoco report to Cobertura'
77+
workingDirectory: '../sample'
6078

6179
- script: |
6280
pod install
@@ -99,6 +117,10 @@ steps:
99117
displayName: 'Running slather'
100118
workingDirectory: '../sample'
101119

120+
- script: reportgenerator "-reports:*.xml" "-reporttypes:cobertura" "-targetdir:."
121+
displayName: 'Merge Android and iOS reports'
122+
workingDirectory: '../sample'
123+
102124
- task: PublishCodeCoverageResults@1
103125
inputs:
104126
codeCoverageTool: 'Cobertura'

sample/android/app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ project.ext.react = [
8181
]
8282

8383
apply from: "../../node_modules/react-native/react.gradle"
84+
apply from: "jacoco.gradle"
8485

8586
/**
8687
* Set this to true to create two separate APKs instead of one:

sample/android/app/jacoco.gradle

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* h8MyJob / PowerMockJacocoDemo
3+
*
4+
* https://github.com/h8MyJob/PowerMockJacocoDemo/wiki
5+
* https://stackoverflow.com/questions/53071373/powermock-jacoco-gradle-0-coverage-for-android-project
6+
*
7+
*/
8+
9+
apply plugin: "jacoco"
10+
11+
configurations {
12+
jacocoAnt
13+
jacocoRuntime
14+
}
15+
16+
jacoco {
17+
toolVersion = "0.8.1"
18+
}
19+
20+
def offline_instrumented_outputDir = "$buildDir.path/intermediates/classes-instrumented/debug"
21+
22+
tasks.withType(Test) {
23+
jacoco.includeNoLocationClasses = true
24+
}
25+
26+
def coverageSourceDirs = [
27+
"../../node_modules/react-native-azurenotificationhub/android/src/main/java"
28+
]
29+
30+
task jacocoTestReport(type: JacocoReport, dependsOn: "test") {
31+
group = "Reporting"
32+
33+
description = "Generate Jacoco coverage reports"
34+
35+
getClassDirectories().setFrom(fileTree(
36+
dir: "../../node_modules/react-native-azurenotificationhub/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes",
37+
excludes: ['**/R.class',
38+
'**/R$*.class',
39+
'**/BuildConfig.*',
40+
'**/MainActivity.*'])
41+
)
42+
43+
getSourceDirectories().setFrom(files(coverageSourceDirs))
44+
getExecutionData().setFrom(files("build/jacoco/testDebugUnitTest.exec"))
45+
}
46+
47+
jacocoTestReport {
48+
reports {
49+
xml.enabled true
50+
xml.destination file("../../jacocoXml.xml")
51+
html.enabled true
52+
html.destination file("build/test-results/jacocoHtml")
53+
}
54+
}
55+
56+
/* This task is used to create offline instrumentation of classes for on-the-fly instrumentation coverage tool like Jacoco. See jacoco classId
57+
* and Offline Instrumentation from the jacoco site for more info.
58+
*
59+
* In this case, some classes mocked using PowerMock were reported as 0% coverage on jacoco & Sonarqube. The issue between PowerMock and jacoco
60+
* is well documented, and a possible solution is offline Instrumentation (not so well documented for gradle).
61+
*
62+
* In a nutshell, this task:
63+
* - Pre-instruments the original *.class files
64+
* - Puts the instrumented classes path at the beginning of the task's classpath (for report purposes)
65+
* - Runs test & generates a new exec file based on the pre-instrumented classes -- as opposed to on-the-fly instrumented class files generated by jacoco.
66+
*
67+
* It is currently not implemented to run prior to any other existing tasks (like test, jacocoTestReport, etc...), therefore, it should be called
68+
* explicitly if Offline Instrumentation report is needed.
69+
*
70+
* Usage: gradle clean & gradle createOfflineInstrTestCoverageReport & gradle jacocoTestReport
71+
* - gradle clean //To prevent influence from any previous task execution
72+
* - gradle createOfflineInstrTestCoverageReport //To generate *.exec file from offline instrumented class
73+
* - gradle jacocoTestReport //To generate html report from newly created *.exec task
74+
*/
75+
task createOfflineTestCoverageReport(dependsOn: ["instrument", "testDebugUnitTest"]) {
76+
doLast {
77+
ant.taskdef(name: "report",
78+
classname: "org.jacoco.ant.ReportTask",
79+
classpath: configurations.jacocoAnt.asPath)
80+
ant.report() {
81+
executiondata {
82+
ant.file(file: "$buildDir.path/jacoco/testDebugUnitTest.exec")
83+
}
84+
structure(name: "React Native Azure Notification Hub Sample") {
85+
classfiles {
86+
fileset(dir: "$project.buildDir/intermediates/javac/debug/compileDebugJavaWithJavac/classes")
87+
}
88+
sourcefiles {
89+
fileset(dir: "../../node_modules/react-native-azurenotificationhub/android/src/main/java")
90+
}
91+
}
92+
}
93+
}
94+
}
95+
96+
/*
97+
* Part of the Offline Instrumentation process is to add the jacoco runtime to the class path along with the path of the instrumented files.
98+
*/
99+
gradle.taskGraph.whenReady { graph ->
100+
if (graph.hasTask(instrument)) {
101+
tasks.withType(Test) {
102+
doFirst {
103+
systemProperty "jacoco-agent.destfile", buildDir.path + "/jacoco/testDebugUnitTest.exec"
104+
classpath = files(offline_instrumented_outputDir) + classpath + configurations.jacocoRuntime
105+
}
106+
}
107+
}
108+
}
109+
110+
/*
111+
* Instruments the classes per se
112+
*/
113+
task instrument(dependsOn: "compileDebugUnitTestSources") {
114+
doLast {
115+
println "Instrumenting classes"
116+
117+
ant.taskdef(name: "instrument",
118+
classname: "org.jacoco.ant.InstrumentTask",
119+
classpath: configurations.jacocoAnt.asPath)
120+
121+
ant.instrument(destdir: offline_instrumented_outputDir) {
122+
fileset(dir: "$buildDir.path/intermediates/javac/debug/compileDebugJavaWithJavac/classes")
123+
}
124+
}
125+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-5.5-all.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-6.2-all.zip
44
zipStoreBase=GRADLE_USER_HOME
55
zipStorePath=wrapper/dists

sample/cover2cover.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# https://github.com/rix0rrr/cover2cover
2+
3+
#!/usr/bin/env python
4+
import sys
5+
import xml.etree.ElementTree as ET
6+
import re
7+
import os.path
8+
9+
# branch-rate="0.0" complexity="0.0" line-rate="1.0"
10+
# branch="true" hits="1" number="86"
11+
12+
def find_lines(j_package, filename):
13+
"""Return all <line> elements for a given source file in a package."""
14+
lines = list()
15+
sourcefiles = j_package.findall("sourcefile")
16+
for sourcefile in sourcefiles:
17+
if sourcefile.attrib.get("name") == os.path.basename(filename):
18+
lines = lines + sourcefile.findall("line")
19+
return lines
20+
21+
def line_is_after(jm, start_line):
22+
return int(jm.attrib.get('line', 0)) > start_line
23+
24+
def method_lines(jmethod, jmethods, jlines):
25+
"""Filter the lines from the given set of jlines that apply to the given jmethod."""
26+
start_line = int(jmethod.attrib.get('line', 0))
27+
larger = list(int(jm.attrib.get('line', 0)) for jm in jmethods if line_is_after(jm, start_line))
28+
end_line = min(larger) if len(larger) else 99999999
29+
30+
for jline in jlines:
31+
if start_line <= int(jline.attrib['nr']) < end_line:
32+
yield jline
33+
34+
def convert_lines(j_lines, into):
35+
"""Convert the JaCoCo <line> elements into Cobertura <line> elements, add them under the given element."""
36+
c_lines = ET.SubElement(into, 'lines')
37+
for jline in j_lines:
38+
mb = int(jline.attrib['mb'])
39+
cb = int(jline.attrib['cb'])
40+
ci = int(jline.attrib['ci'])
41+
42+
cline = ET.SubElement(c_lines, 'line')
43+
cline.set('number', jline.attrib['nr'])
44+
cline.set('hits', '1' if ci > 0 else '0') # Probably not true but no way to know from JaCoCo XML file
45+
46+
if mb + cb > 0:
47+
percentage = str(int(100 * (float(cb) / (float(cb) + float(mb))))) + '%'
48+
cline.set('branch', 'true')
49+
cline.set('condition-coverage', percentage + ' (' + str(cb) + '/' + str(cb + mb) + ')')
50+
51+
cond = ET.SubElement(ET.SubElement(cline, 'conditions'), 'condition')
52+
cond.set('number', '0')
53+
cond.set('type', 'jump')
54+
cond.set('coverage', percentage)
55+
else:
56+
cline.set('branch', 'false')
57+
58+
def guess_filename(path_to_class):
59+
m = re.match('([^$]*)', path_to_class)
60+
return (m.group(1) if m else path_to_class) + '.java'
61+
62+
def add_counters(source, target):
63+
target.set('line-rate', counter(source, 'LINE'))
64+
target.set('branch-rate', counter(source, 'BRANCH'))
65+
target.set('complexity', counter(source, 'COMPLEXITY', sum))
66+
67+
def fraction(covered, missed):
68+
return covered / (covered + missed)
69+
70+
def sum(covered, missed):
71+
return covered + missed
72+
73+
def counter(source, type, operation=fraction):
74+
cs = source.findall('counter')
75+
c = next((ct for ct in cs if ct.attrib.get('type') == type), None)
76+
77+
if c is not None:
78+
covered = float(c.attrib['covered'])
79+
missed = float(c.attrib['missed'])
80+
81+
return str(operation(covered, missed))
82+
else:
83+
return '0.0'
84+
85+
def convert_method(j_method, j_lines):
86+
c_method = ET.Element('method')
87+
c_method.set('name', j_method.attrib['name'])
88+
c_method.set('signature', j_method.attrib['desc'])
89+
90+
add_counters(j_method, c_method)
91+
convert_lines(j_lines, c_method)
92+
93+
return c_method
94+
95+
def convert_class(j_class, j_package):
96+
c_class = ET.Element('class')
97+
c_class.set('name', j_class.attrib['name'].replace('/', '.'))
98+
c_class.set('filename', guess_filename(j_class.attrib['name']))
99+
100+
all_j_lines = list(find_lines(j_package, c_class.attrib['filename']))
101+
102+
c_methods = ET.SubElement(c_class, 'methods')
103+
all_j_methods = list(j_class.findall('method'))
104+
for j_method in all_j_methods:
105+
j_method_lines = method_lines(j_method, all_j_methods, all_j_lines)
106+
c_methods.append(convert_method(j_method, j_method_lines))
107+
108+
add_counters(j_class, c_class)
109+
convert_lines(all_j_lines, c_class)
110+
111+
return c_class
112+
113+
def convert_package(j_package):
114+
c_package = ET.Element('package')
115+
c_package.attrib['name'] = j_package.attrib['name'].replace('/', '.')
116+
117+
c_classes = ET.SubElement(c_package, 'classes')
118+
for j_class in j_package.findall('class'):
119+
c_classes.append(convert_class(j_class, j_package))
120+
121+
add_counters(j_package, c_package)
122+
123+
return c_package
124+
125+
def convert_root(source, target, source_roots):
126+
target.set('timestamp', str(int(source.find('sessioninfo').attrib['start']) / 1000))
127+
128+
sources = ET.SubElement(target, 'sources')
129+
for s in source_roots:
130+
ET.SubElement(sources, 'source').text = s
131+
132+
packages = ET.SubElement(target, 'packages')
133+
for package in source.findall('package'):
134+
packages.append(convert_package(package))
135+
136+
add_counters(source, target)
137+
138+
def jacoco2cobertura(filename, source_roots):
139+
if filename == '-':
140+
root = ET.fromstring(sys.stdin.read())
141+
else:
142+
tree = ET.parse(filename)
143+
root = tree.getroot()
144+
145+
into = ET.Element('coverage')
146+
convert_root(root, into, source_roots)
147+
print '<?xml version="1.0" ?>'
148+
print ET.tostring(into)
149+
150+
if __name__ == '__main__':
151+
if len(sys.argv) < 2:
152+
print "Usage: cover2cover.py FILENAME [SOURCE_ROOTS]"
153+
sys.exit(1)
154+
155+
filename = sys.argv[1]
156+
source_roots = sys.argv[2:] if 2 < len(sys.argv) else '.'
157+
158+
jacoco2cobertura(filename, source_roots)

0 commit comments

Comments
 (0)