diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 6c399841e..14cfc2446 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -23,18 +23,27 @@ stages:
- build
- startReview
- test
+ - report
- publish
- commit
- slackRelease
+variables:
+ # Use fastzip to improve cache times
+ FF_USE_FASTZIP: "true"
+ # Output upload and download progress every 5 seconds
+ TRANSFER_METER_FREQUENCY: "5s"
+ # Use no compression for artifacts
+ ARTIFACT_COMPRESSION_LEVEL: "fast"
+ # Use no compression for caches
+ CACHE_COMPRESSION_LEVEL: "fastest"
+
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- - .gradle
- - '**/build'
- - '**/**/build'
- - '**/**/**/build'
-
+ - .gradle/caches/modules-*
+ - .gradle/caches/jars-*
+ - ./**/build
#####################
danger-review:
@@ -83,8 +92,6 @@ assemble:
- coreexample/build/outputs/
debugTests:
- cache:
- policy: pull
stage: test
tags:
- large
@@ -188,3 +195,20 @@ startReview:
stopReview:
extends: .stopReview
+
+coverage report:
+ stage: report
+ tags:
+ - medium
+ script:
+ - ./gradlew coberturaCoverageReport
+ coverage: /Total.*?(\d{1,3}\.\d{0,2})%/
+ allow_failure: true
+ artifacts:
+ expire_in: 1 week
+ paths:
+ - ./build/reports/*
+ reports:
+ cobertura:
+ - ./**/build/reports/cobertura-coverage.xml
+
diff --git a/README.md b/README.md
index 7f116bf03..e07e88944 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,8 @@ In order to use the all-in-one Detekt configuration, you have to:
Detekt: **0.4** - _released on: Sep 13, 2021_
+Jacoco: **0.1** - _released on: Nvo 10, 2021_
+
Kotlin: **0.1** - _released on: Oct 09, 2020_
Tests: **0.1** - _released on: Oct 09, 2020_
diff --git a/build.gradle.kts b/build.gradle.kts
index 7a80243e4..1f81d0ecc 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -30,6 +30,7 @@ import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
plugins {
id("core")
id("me.proton.detekt")
+ id("me.proton.jacoco")
id("me.proton.kotlin")
id("me.proton.publish-libraries")
id("me.proton.tests")
@@ -43,12 +44,14 @@ buildscript {
val kotlinVersion = "1.5.30" // Aug 23, 2021
val dokkaVersion = "1.4.10.2" // Oct 20, 2020
val hiltVersion = "2.38.1" // Jul 27, 2021
+ val jacocoVersion = "0.8.7" // May 5, 2021
classpath(kotlin("gradle-plugin", kotlinVersion))
classpath(kotlin("serialization", kotlinVersion))
classpath("org.jetbrains.dokka:dokka-gradle-plugin:$dokkaVersion")
classpath(libs.android.pluginGradle)
classpath("com.google.dagger:hilt-android-gradle-plugin:$hiltVersion")
+ classpath("org.jacoco:org.jacoco.core:$jacocoVersion")
}
}
diff --git a/plugins/jacoco/build.gradle.kts b/plugins/jacoco/build.gradle.kts
new file mode 100644
index 000000000..00a7ebb39
--- /dev/null
+++ b/plugins/jacoco/build.gradle.kts
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2020 Proton Technologies AG
+ * This file is part of Proton Technologies AG and ProtonCore.
+ *
+ * ProtonCore is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ProtonCore is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with ProtonCore. If not, see .
+ */
+
+import org.gradle.kotlin.dsl.`java-gradle-plugin`
+import org.gradle.kotlin.dsl.`kotlin-dsl`
+import org.gradle.kotlin.dsl.dependencies
+import org.gradle.kotlin.dsl.gradlePlugin
+import org.gradle.kotlin.dsl.implementation
+import org.gradle.kotlin.dsl.kotlin
+import org.gradle.kotlin.dsl.repositories
+import studio.forface.easygradle.dsl.Version
+
+plugins {
+ `kotlin-dsl`
+ kotlin("jvm")
+ `java-gradle-plugin`
+ id("me.proton.publish-plugins")
+}
+
+val plugin = PluginConfig(
+ name = "Jacoco",
+ version = Version(0, 1)
+)
+pluginConfig = plugin
+
+group = plugin.group
+version = plugin.version
+
+gradlePlugin {
+ plugins {
+ create("${plugin.id}") {
+ id = plugin.id
+ implementationClass = "ProtonJacocoPlugin"
+ version = plugin.version
+ }
+ }
+}
+
+repositories {
+ google()
+ mavenCentral()
+ jcenter()
+}
+
+dependencies {
+ val jacocoVersion = "0.8.7"
+
+ implementation(gradleApi())
+ implementation(kotlin("gradle-plugin"))
+ implementation("org.jacoco:org.jacoco.core:$jacocoVersion")
+ implementation(libs.easyGradle.dsl)
+}
diff --git a/plugins/jacoco/scripts/cover2cover.py b/plugins/jacoco/scripts/cover2cover.py
new file mode 100644
index 000000000..ff8af3be7
--- /dev/null
+++ b/plugins/jacoco/scripts/cover2cover.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python
+import sys
+import xml.etree.ElementTree as ET
+import re
+import os.path
+import time
+
+# branch-rate="0.0" complexity="0.0" line-rate="1.0"
+# branch="true" hits="1" number="86"
+
+def find_lines(j_package, filename):
+ """Return all elements for a given source file in a package."""
+ lines = list()
+ sourcefiles = j_package.findall("sourcefile")
+ for sourcefile in sourcefiles:
+ if sourcefile.attrib.get("name") == os.path.basename(filename):
+ lines = lines + sourcefile.findall("line")
+ return lines
+
+def line_is_after(jm, start_line):
+ return int(jm.attrib.get('line', 0)) > start_line
+
+def method_lines(jmethod, jmethods, jlines):
+ """Filter the lines from the given set of jlines that apply to the given jmethod."""
+ start_line = int(jmethod.attrib.get('line', 0))
+ larger = list(int(jm.attrib.get('line', 0)) for jm in jmethods if line_is_after(jm, start_line))
+ end_line = min(larger) if len(larger) else 99999999
+
+ for jline in jlines:
+ if start_line <= int(jline.attrib['nr']) < end_line:
+ yield jline
+
+def convert_lines(j_lines, into):
+ """Convert the JaCoCo elements into Cobertura elements, add them under the given element."""
+ c_lines = ET.SubElement(into, 'lines')
+ for jline in j_lines:
+ mb = int(jline.attrib['mb'])
+ cb = int(jline.attrib['cb'])
+ ci = int(jline.attrib['ci'])
+
+ cline = ET.SubElement(c_lines, 'line')
+ cline.set('number', jline.attrib['nr'])
+ cline.set('hits', '1' if ci > 0 else '0') # Probably not true but no way to know from JaCoCo XML file
+
+ if mb + cb > 0:
+ percentage = str(int(100 * (float(cb) / (float(cb) + float(mb))))) + '%'
+ cline.set('branch', 'true')
+ cline.set('condition-coverage', percentage + ' (' + str(cb) + '/' + str(cb + mb) + ')')
+
+ cond = ET.SubElement(ET.SubElement(cline, 'conditions'), 'condition')
+ cond.set('number', '0')
+ cond.set('type', 'jump')
+ cond.set('coverage', percentage)
+ else:
+ cline.set('branch', 'false')
+
+def path_to_filepath(path_to_class, sourcefilename):
+ return path_to_class[0: path_to_class.rfind("/") + 1] + sourcefilename
+
+def add_counters(source, target):
+ target.set('line-rate', counter(source, 'LINE'))
+ target.set('branch-rate', counter(source, 'BRANCH'))
+ target.set('complexity', counter(source, 'COMPLEXITY', sum))
+
+def fraction(covered, missed):
+ return covered / (covered + missed)
+
+def sum(covered, missed):
+ return covered + missed
+
+def counter(source, type, operation=fraction):
+ cs = source.findall('counter')
+ c = next((ct for ct in cs if ct.attrib.get('type') == type), None)
+
+ if c is not None:
+ covered = float(c.attrib['covered'])
+ missed = float(c.attrib['missed'])
+
+ return str(operation(covered, missed))
+ else:
+ return '0.0'
+
+def convert_method(j_method, j_lines):
+ c_method = ET.Element('method')
+ c_method.set('name', j_method.attrib['name'])
+ c_method.set('signature', j_method.attrib['desc'])
+
+ add_counters(j_method, c_method)
+ convert_lines(j_lines, c_method)
+
+ return c_method
+
+def convert_class(j_class, j_package):
+ c_class = ET.Element('class')
+ c_class.set('name', j_class.attrib['name'].replace('/', '.'))
+ c_class.set('filename', path_to_filepath(j_class.attrib['name'], j_class.attrib['sourcefilename']))
+
+ all_j_lines = list(find_lines(j_package, c_class.attrib['filename']))
+
+ c_methods = ET.SubElement(c_class, 'methods')
+ all_j_methods = list(j_class.findall('method'))
+ for j_method in all_j_methods:
+ j_method_lines = method_lines(j_method, all_j_methods, all_j_lines)
+ c_methods.append(convert_method(j_method, j_method_lines))
+
+ add_counters(j_class, c_class)
+ convert_lines(all_j_lines, c_class)
+
+ return c_class
+
+def convert_package(j_package):
+ c_package = ET.Element('package')
+ c_package.attrib['name'] = j_package.attrib['name'].replace('/', '.')
+
+ c_classes = ET.SubElement(c_package, 'classes')
+ for j_class in j_package.findall('class'):
+ if 'sourcefilename' in j_class.attrib:
+ c_classes.append(convert_class(j_class, j_package))
+
+ add_counters(j_package, c_package)
+
+ return c_package
+
+def convert_root(source, target, source_roots):
+ try:
+ target.set('timestamp', str(int(source.find('sessioninfo').attrib['start']) / 1000))
+ except AttributeError as e:
+ target.set('timestamp', str(int(time.time() / 1000)))
+ sources = ET.SubElement(target, 'sources')
+ for s in source_roots:
+ ET.SubElement(sources, 'source').text = s
+
+ packages = ET.SubElement(target, 'packages')
+
+ for group in source.findall('group'):
+ for package in group.findall('package'):
+ packages.append(convert_package(package))
+
+ for package in source.findall('package'):
+ packages.append(convert_package(package))
+
+ add_counters(source, target)
+
+def jacoco2cobertura(filename, source_roots):
+ if filename == '-':
+ root = ET.fromstring(sys.stdin.read())
+ else:
+ tree = ET.parse(filename)
+ root = tree.getroot()
+
+ into = ET.Element('coverage')
+ convert_root(root, into, source_roots)
+ print('')
+ print(ET.tostring(into, encoding='unicode'))
+
+if __name__ == '__main__':
+ if len(sys.argv) < 2:
+ print("Usage: cover2cover.py FILENAME [SOURCE_ROOTS]")
+ sys.exit(1)
+
+ filename = sys.argv[1]
+ source_roots = sys.argv[2:] if 2 < len(sys.argv) else '.'
+
+ jacoco2cobertura(filename, source_roots)
diff --git a/plugins/jacoco/src/main/kotlin/jacoco/ProtonJacocoPlugin.kt b/plugins/jacoco/src/main/kotlin/jacoco/ProtonJacocoPlugin.kt
new file mode 100644
index 000000000..7eaf320f4
--- /dev/null
+++ b/plugins/jacoco/src/main/kotlin/jacoco/ProtonJacocoPlugin.kt
@@ -0,0 +1,215 @@
+/*
+ * Copyright (c) 2021 Proton Technologies AG
+ * This file is part of Proton Technologies AG and ProtonCore.
+ *
+ * ProtonCore is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ProtonCore is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with ProtonCore. If not, see .
+ */
+
+import groovy.xml.slurpersupport.Node
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.tasks.Exec
+import org.gradle.api.tasks.testing.Test
+import org.gradle.internal.impldep.org.junit.experimental.categories.Categories.CategoryFilter.exclude
+import org.gradle.internal.impldep.org.junit.experimental.categories.Categories.CategoryFilter.include
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.getByName
+import org.gradle.kotlin.dsl.named
+import org.gradle.kotlin.dsl.register
+import org.gradle.kotlin.dsl.withType
+import org.gradle.testing.jacoco.plugins.JacocoPlugin
+import org.gradle.testing.jacoco.plugins.JacocoPluginExtension
+import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
+import org.gradle.testing.jacoco.tasks.JacocoReport
+import java.io.File
+import java.io.FileOutputStream
+import java.util.Locale
+
+class ProtonJacocoPlugin : Plugin {
+
+ companion object {
+ const val JacocoVersion = "0.8.7"
+ }
+
+ fun Project.getReportTasks(jacocoReport: JacocoReport): List {
+ return allprojects.flatMap {
+ it.tasks.withType().filter { it.name == "jacocoTestReport" }
+ .filter { report -> report != jacocoReport }
+ }
+ }
+
+ override fun apply(target: Project) {
+ with(target) {
+ subprojects {
+ afterEvaluate {
+ configureJacocoTestTask()
+ }
+ }
+
+ configureCoberturaConversion()
+ }
+ }
+
+ private fun Project.configureCoberturaConversion() {
+ plugins.apply(JacocoPlugin::class)
+ configure {
+ toolVersion = JacocoVersion
+ }
+
+ val defaultReportsDir = file("$buildDir/reports/jacoco/jacocoTestReport")
+ val reportFile = File(defaultReportsDir, "jacocoTestReport.xml")
+
+ tasks.register("jacocoMergeReport") {
+ group = "Verification"
+ description = "Generate Jacoco aggregate report for all modules"
+
+ val jacocoSubTasks = getReportTasks(this)
+ dependsOn(jacocoSubTasks)
+
+ val sourceDirs = jacocoSubTasks.flatMap { it.sourceDirectories }
+ val source = files(sourceDirs)
+ additionalSourceDirs.setFrom(source)
+ sourceDirectories.setFrom(source)
+
+ val classDirs = jacocoSubTasks.flatMap { it.classDirectories }
+ classDirectories.setFrom(files(classDirs))
+
+ val jacocoExecs = jacocoSubTasks.flatMap { it.executionData }
+ executionData.setFrom(files(jacocoExecs))
+
+ reports {
+ html.required.set(true)
+ html.outputLocation.set(defaultReportsDir)
+ xml.required.set(true)
+ xml.outputLocation.set(reportFile)
+ }
+
+ doLast { println("Writing aggregated report to: $reportFile") }
+ }
+
+ tasks.register("coverageReport") {
+ dependsOn("jacocoMergeReport")
+
+ doLast {
+ val slurper = groovy.xml.XmlSlurper()
+ slurper.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false)
+ slurper.setFeature(
+ "http://apache.org/xml/features/nonvalidating/load-external-dtd",
+ false
+ )
+ val xml = slurper.parse(reportFile)
+ val counter = xml.childNodes().asSequence().firstOrNull {
+ (it as? Node)?.name() == "counter" && it.attributes()["type"] == "INSTRUCTION"
+ } as? Node ?: return@doLast
+ val missed = (counter.attributes()["missed"] as String).toInt()
+ val covered = (counter.attributes()["covered"] as String).toInt()
+ val total = (missed + covered).toFloat()
+ val percentage = (covered / total * 100.0f)
+
+ // This will print the total percentage into the build logs. Gitlab will then parse that line
+ // and show the coverage percentage in the MR info and repo statistics.
+ // See: https://docs.gitlab.com/ee/ci/pipelines/settings.html#add-test-coverage-results-to-a-merge-request
+ println("Missed %d branches".format(missed))
+ println("Covered %d branches".format(covered))
+ println("Total %.2f%%".format(Locale.US, percentage))
+ }
+ }
+
+ tasks.register("coberturaCoverageReport") {
+ dependsOn("coverageReport")
+
+ val outputDir = File(buildDir, "reports")
+ if (!outputDir.exists()) {
+ outputDir.mkdirs()
+ }
+ workingDir = rootDir
+
+ val jacocoSubTasks = getReportTasks(tasks.named("jacocoMergeReport").get())
+ val sources = jacocoSubTasks.flatMap { it.sourceDirectories }
+ .map { it.absolutePath }
+
+ doFirst { standardOutput = FileOutputStream(File(outputDir, "cobertura-coverage.xml")) }
+
+ // Convert Jacoco merged coverage report into a Cobertura one, which is supported by Gitlab.
+ // See: https://docs.gitlab.com/ee/user/project/merge_requests/test_coverage_visualization.html#java-and-kotlin-examples
+ commandLine(
+ "python3",
+ "$rootDir/plugins/jacoco/scripts/cover2cover.py",
+ reportFile.absolutePath,
+ *(sources.toTypedArray()),
+ )
+ }
+ }
+
+ private fun Project.configureJacocoTestTask(srcFolder: String = "kotlin") {
+ val hasSourceDirs = file("$projectDir/src/main/$srcFolder").exists()
+
+ // Don't setup Jacoco if there are no sources
+ if (!hasSourceDirs) return
+
+ plugins.apply(JacocoPlugin::class)
+ configure {
+ toolVersion = JacocoVersion
+ }
+
+ val defaultReportsDir = file("$buildDir/reports/jacoco/jacocoTestReport")
+ val reportFile = File(defaultReportsDir, "jacocoTestReport.xml")
+
+ afterEvaluate {
+ val jacocoConfig: JacocoReport.() -> Unit = {
+ reports {
+ xml.required.set(true)
+ xml.outputLocation.set(reportFile)
+ html.required.set(false)
+ }
+
+ val fileFilter = listOf(
+ "**/R.class",
+ "**/R$*.class",
+ "**/BuildConfig.*",
+ "**/Manifest*.*",
+ "**/*Test*.*",
+ "android/**/*.*",
+ "ch.protonmail.android.utils.nativelib",
+ "**/ch/protonmail/**",
+ )
+
+ val debugTree = fileTree("$buildDir/tmp/kotlin-classes/debug") { exclude(fileFilter) }
+ val mainSrc = "$projectDir/src/main/$srcFolder"
+
+ sourceDirectories.setFrom(mainSrc)
+ classDirectories.setFrom(debugTree)
+ executionData.setFrom(fileTree(buildDir) { include(listOf("**/*.exec", "**/*.ec")) })
+ }
+
+ tasks.withType {
+ configure {
+ isIncludeNoLocationClasses = true
+ excludes = listOf("jdk.internal.*")
+ }
+ }
+
+ val isJacocoRegistered = tasks.findByName("jacocoTestReport") != null
+
+ if (!isJacocoRegistered) {
+ println("Registering Jacoco for $name")
+ tasks.register("jacocoTestReport", jacocoConfig)
+ } else {
+ println("Configuring registered Jacoco for $name")
+ tasks.getByName("jacocoTestReport", jacocoConfig)
+ }
+ }
+ }
+}
diff --git a/plugins/settings.gradle.kts b/plugins/settings.gradle.kts
index 51985b87e..e007380b1 100644
--- a/plugins/settings.gradle.kts
+++ b/plugins/settings.gradle.kts
@@ -23,6 +23,7 @@ includeBuild("publish")
include(
"core",
"detekt",
+ "jacoco",
"kotlin",
"tests"
)