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" )