Skip to content

Commit 447a1dd

Browse files
committed
Implement the plugin and its functionalities
1 parent 3c97fa4 commit 447a1dd

30 files changed

+2931
-5
lines changed

LICENSE

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
MIT License
2+
===========
23

34
Copyright (c) 2023 Aziz Utku Kagitci
45

@@ -18,4 +19,24 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1819
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1920
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2021
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21-
SOFTWARE.
22+
SOFTWARE.
23+
24+
---
25+
26+
Third-Party Software Licenses
27+
=============================
28+
29+
JaCoCo License
30+
--------------
31+
32+
Copyright (c) 2009, 2023 Mountainminds GmbH & Co. KG and Contributors
33+
34+
The JaCoCo Java Code Coverage Library and all included documentation is made
35+
available by Mountainminds GmbH & Co. KG, Munich. Except indicated below, the
36+
Content is provided to you under the terms and conditions of the Eclipse Public
37+
License Version 2.0 ("EPL"). A copy of the EPL is available at
38+
[https://www.eclipse.org/legal/epl-2.0/](https://www.eclipse.org/legal/epl-2.0/).
39+
40+
Please visit
41+
[http://www.jacoco.org/jacoco/trunk/doc/license.html](http://www.jacoco.org/jacoco/trunk/doc/license.html)
42+
for the complete license information including third party licenses and trademarks.

jacocoaggregatecoverageplugin/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ plugins {
1212
dependencies {
1313
compileOnly(libs.android.gradle.api)
1414
detektPlugins(libs.bundles.detekt)
15+
implementation("org.jsoup:jsoup:1.16.2")
1516
}
1617

1718
java {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package com.azizutku.jacocoaggregatecoverageplugin
2+
3+
import com.azizutku.jacocoaggregatecoverageplugin.extensions.JacocoAggregateCoveragePluginExtension
4+
import com.azizutku.jacocoaggregatecoverageplugin.models.CoverageMetrics
5+
import com.azizutku.jacocoaggregatecoverageplugin.models.ModuleCoverageRow
6+
import com.azizutku.jacocoaggregatecoverageplugin.utils.HtmlCodeGenerator
7+
import org.gradle.api.DefaultTask
8+
import org.gradle.api.Project
9+
import org.gradle.api.file.ConfigurableFileCollection
10+
import org.gradle.api.file.DirectoryProperty
11+
import org.gradle.api.provider.Property
12+
import org.gradle.api.tasks.CacheableTask
13+
import org.gradle.api.tasks.InputDirectory
14+
import org.gradle.api.tasks.InputFiles
15+
import org.gradle.api.tasks.OutputDirectory
16+
import org.gradle.api.tasks.PathSensitive
17+
import org.gradle.api.tasks.PathSensitivity
18+
import org.gradle.api.tasks.TaskAction
19+
import org.jsoup.Jsoup
20+
import java.io.File
21+
import java.io.IOException
22+
23+
private const val TOTAL_COVERAGE_PLACEHOLDER = "TOTAL_COVERAGE_PLACEHOLDER"
24+
private const val LINKED_MODULES_PLACEHOLDER = "LINKED_MODULES_PLACEHOLDER"
25+
26+
/**
27+
* A Gradle task that generates a unified HTML report from individual JaCoCo report files.
28+
* This task is responsible for collating the coverage metrics from multiple subprojects
29+
* and presenting them in a single, easily navigable HTML document.
30+
*/
31+
@CacheableTask
32+
internal abstract class AggregateJacocoReportsTask : DefaultTask() {
33+
/**
34+
* The source folder that contains the plugin's resources.
35+
*/
36+
@get:InputDirectory
37+
@get:PathSensitive(PathSensitivity.RELATIVE)
38+
abstract val pluginSourceFolder: Property<File>
39+
40+
/**
41+
* The set of JaCoCo report files for Gradle's incremental build checks.
42+
*/
43+
@get:InputFiles
44+
@get:PathSensitive(PathSensitivity.RELATIVE)
45+
abstract val jacocoReportsFileCollection: ConfigurableFileCollection
46+
47+
/**
48+
* The directory where the aggregated JaCoCo reports will be generated and stored.
49+
*/
50+
@get:OutputDirectory
51+
abstract val outputDirectory: DirectoryProperty
52+
53+
/**
54+
* Lazily instantiated [HtmlCodeGenerator] for generating report HTML.
55+
*/
56+
private val htmlCodeGenerator by lazy { HtmlCodeGenerator() }
57+
58+
/**
59+
* A lazy-initialized map of subproject paths to their respective coverage metrics.
60+
* This map is used for generating the aggregated report.
61+
*/
62+
private val subprojectToCoverageMap: Map<String, CoverageMetrics> by lazy {
63+
project.subprojects.associate {
64+
it.path to parseCoverageMetrics(it)
65+
}.filterValues { it != null }.mapValues { it.value!! }
66+
}
67+
68+
/**
69+
* Executes the task action to create the unified index HTML.
70+
* This function orchestrates the reading of individual coverage reports,
71+
* aggregates the coverage data, and produces a single index HTML file
72+
* that represents the aggregated coverage information.
73+
*/
74+
@TaskAction
75+
fun createUnifiedIndexHtml() {
76+
val maximumInstructionTotal = subprojectToCoverageMap.values.maxOfOrNull {
77+
it.instructionsTotal
78+
} ?: 0
79+
val maximumBranchesTotal = subprojectToCoverageMap.values.maxOfOrNull {
80+
it.branchesTotal
81+
} ?: 0
82+
83+
val tableBodyForModules =
84+
subprojectToCoverageMap.entries
85+
.joinToString(separator = "\n") { (moduleName, moduleCoverage) ->
86+
createModuleCoverageRow(
87+
moduleName = moduleName,
88+
moduleCoverage = moduleCoverage,
89+
maxInstructionTotal = maximumInstructionTotal,
90+
maxBranchesTotal = maximumBranchesTotal,
91+
)
92+
}
93+
94+
val unifiedMetrics =
95+
subprojectToCoverageMap.values.fold(CoverageMetrics()) { acc, metrics ->
96+
acc + metrics
97+
}
98+
99+
updateBreadcrumbs()
100+
buildUnifiedIndexHtml(unifiedMetrics, tableBodyForModules)
101+
}
102+
103+
/**
104+
* Updates the breadcrumbs for report navigation in the generated HTML files.
105+
* This method modifies the individual index HTML files of the subprojects
106+
* to include a link back to the root unified report.
107+
*/
108+
private fun updateBreadcrumbs() {
109+
project.subprojects.forEach { subproject ->
110+
val indexHtmlFile = outputDirectory.dir(subproject.path)
111+
.get().file("index.html").asFile
112+
if (indexHtmlFile.exists().not()) {
113+
return@forEach
114+
}
115+
val document = Jsoup.parse(indexHtmlFile, Charsets.UTF_8.name())
116+
val spanElement = document.select("span.el_report").first()
117+
118+
if (spanElement != null) {
119+
val newAnchor = document.createElement("a")
120+
newAnchor.attr("href", "../index.html")
121+
newAnchor.addClass("el_report")
122+
newAnchor.text("root")
123+
124+
val newSpan = document.createElement("span")
125+
newSpan.addClass("el_package")
126+
newSpan.text(subproject.path)
127+
128+
spanElement.before(newAnchor)
129+
newAnchor.after(newSpan).after(" &gt; ")
130+
131+
spanElement.remove()
132+
}
133+
134+
indexHtmlFile.writeText(document.outerHtml())
135+
}
136+
}
137+
138+
/**
139+
* Parses coverage metrics from a subproject's JaCoCo report.
140+
*
141+
* @param subproject The subproject from which to parse coverage metrics.
142+
* @return A [CoverageMetrics] containing the parsed coverage data or null
143+
* if no data is found.
144+
*/
145+
private fun parseCoverageMetrics(subproject: Project): CoverageMetrics? {
146+
val pluginExtension =
147+
project.extensions.getByType(JacocoAggregateCoveragePluginExtension::class.java)
148+
val generatedReportDirectory = pluginExtension.getReportDirectory()
149+
val indexHtmlFileProvider = subproject.layout.buildDirectory
150+
.file("$generatedReportDirectory/index.html")
151+
152+
return CoverageMetrics.parseModuleCoverageMetrics(indexHtmlFileProvider)
153+
}
154+
155+
/**
156+
* Creates an HTML table row representing the coverage data for a module.
157+
* This row includes progress bars and percentages for different coverage metrics.
158+
*
159+
* @param moduleName The name of the module.
160+
* @param moduleCoverage The coverage metrics for the module.
161+
* @param maxInstructionTotal The maximum total instructions for scaling the progress bar.
162+
* @param maxBranchesTotal The maximum total branches for scaling the progress bar.
163+
* @return An HTML string representing the table row.
164+
*/
165+
private fun createModuleCoverageRow(
166+
moduleName: String,
167+
moduleCoverage: CoverageMetrics,
168+
maxInstructionTotal: Int,
169+
maxBranchesTotal: Int,
170+
): String {
171+
val moduleCoverageRow = ModuleCoverageRow.generateModuleCoverageRow(
172+
moduleName = moduleName,
173+
moduleCoverage = moduleCoverage,
174+
maxInstructionTotal = maxInstructionTotal,
175+
maxBranchesTotal = maxBranchesTotal,
176+
subprojectToCoverageMap = subprojectToCoverageMap,
177+
)
178+
return htmlCodeGenerator.generateModuleCoverageTableRowHtml(
179+
moduleName = moduleName,
180+
coverageMetrics = moduleCoverage,
181+
moduleCoverageRow = moduleCoverageRow,
182+
)
183+
}
184+
185+
/**
186+
* Builds the final unified index HTML file using a template and the coverage data.
187+
* Replaces placeholders in the template with actual coverage data and module links.
188+
*
189+
* @param metrics The aggregated coverage metrics.
190+
* @param linksHtml The HTML string containing links to the individual module reports.
191+
*/
192+
private fun buildUnifiedIndexHtml(metrics: CoverageMetrics, linksHtml: String) {
193+
try {
194+
val templateFile = project.file("${pluginSourceFolder.get().path}/html/index.html")
195+
val templateText = templateFile.readText(Charsets.UTF_8)
196+
197+
val totalCoverageString = htmlCodeGenerator.createTotalCoverageString(metrics)
198+
val newText = templateText
199+
.replace(TOTAL_COVERAGE_PLACEHOLDER, totalCoverageString)
200+
.replace(LINKED_MODULES_PLACEHOLDER, linksHtml)
201+
202+
val destination = outputDirectory.file("index.html").get().asFile
203+
destination.writeText(newText, Charsets.UTF_8)
204+
logger.lifecycle("Aggregated report is generated at: ${destination.absolutePath}")
205+
} catch (exception: IOException) {
206+
logger.error("Error occurred while aggregating reports: ${exception.message}")
207+
}
208+
}
209+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package com.azizutku.jacocoaggregatecoverageplugin
2+
3+
import com.azizutku.jacocoaggregatecoverageplugin.extensions.JacocoAggregateCoveragePluginExtension
4+
import org.gradle.api.DefaultTask
5+
import org.gradle.api.file.ConfigurableFileCollection
6+
import org.gradle.api.file.DirectoryProperty
7+
import org.gradle.api.provider.Property
8+
import org.gradle.api.tasks.CacheableTask
9+
import org.gradle.api.tasks.InputDirectory
10+
import org.gradle.api.tasks.InputFiles
11+
import org.gradle.api.tasks.Internal
12+
import org.gradle.api.tasks.OutputDirectory
13+
import org.gradle.api.tasks.PathSensitive
14+
import org.gradle.api.tasks.PathSensitivity
15+
import org.gradle.api.tasks.TaskAction
16+
import java.io.File
17+
18+
/**
19+
* A Gradle task to copy JaCoCo reports from all subprojects into a single directory.
20+
* It aggregates all the coverage data into one place for easier access and management.
21+
* This task is essential for creating a unified view of test coverage across multiple modules.
22+
*/
23+
@CacheableTask
24+
internal abstract class CopyJacocoReportsTask : DefaultTask() {
25+
26+
/**
27+
* The source folder containing plugin resources.
28+
*/
29+
@get:InputDirectory
30+
@get:PathSensitive(PathSensitivity.RELATIVE)
31+
abstract val pluginSourceFolder: Property<File>
32+
33+
/**
34+
* The set of JaCoCo report files for Gradle's incremental build checks.
35+
*/
36+
@get:InputFiles
37+
@get:PathSensitive(PathSensitivity.RELATIVE)
38+
abstract val jacocoReportsFileCollection: ConfigurableFileCollection
39+
40+
/**
41+
* The directory where the required resources will be stored.
42+
*/
43+
@get:OutputDirectory
44+
abstract val outputDirectoryResources: DirectoryProperty
45+
46+
/**
47+
* The directory for the generated aggregated coverage report.
48+
*/
49+
@get:Internal
50+
abstract val aggregatedReportDir: DirectoryProperty
51+
52+
/**
53+
* Performs the action of copying required resources and JaCoCo reports from all subprojects.
54+
* This method is invoked when the task executes. It checks for the existence of
55+
* JaCoCo reports in each subproject and copies them into a unified directory.
56+
*/
57+
@TaskAction
58+
fun copyJacocoReports() {
59+
val pluginExtension =
60+
project.extensions.getByType(JacocoAggregateCoveragePluginExtension::class.java)
61+
val reportDirectory = pluginExtension.getReportDirectory()
62+
if (reportDirectory == null) {
63+
logger.error(
64+
"You need to specify jacocoTestReportTask property of " +
65+
"jacocoAggregateCoverage extension block in your root build gradle"
66+
)
67+
return
68+
}
69+
copyRequiredResources(outputDirectoryResources.get().asFile)
70+
71+
var foundAny = false
72+
project.subprojects.forEach { subproject ->
73+
val jacocoReportDir = subproject.layout.buildDirectory
74+
.dir(reportDirectory)
75+
.get()
76+
.asFile
77+
78+
if (jacocoReportDir.exists()) {
79+
project.copy {
80+
from(jacocoReportDir)
81+
into(aggregatedReportDir.get().dir(subproject.path))
82+
}
83+
foundAny = true
84+
}
85+
}
86+
87+
if (foundAny.not()) {
88+
logger.error(
89+
"There is no generated test report, you should " +
90+
"run `${pluginExtension.jacocoTestReportTask.get()}` task first, " +
91+
"then call `aggregateJacocoReports`. Or you can run " +
92+
"`generateAndAggregateJacocoReports` task directly."
93+
)
94+
}
95+
}
96+
97+
/**
98+
* Copies JaCoCo-related resources to the specified unified report directory.
99+
*
100+
* @param unifiedReportDir The target directory to copy resources into.
101+
*/
102+
private fun copyRequiredResources(unifiedReportDir: File) {
103+
project.copy {
104+
from(project.file("${pluginSourceFolder.get().path}/jacoco-resources/"))
105+
into(unifiedReportDir)
106+
}
107+
}
108+
}

0 commit comments

Comments
 (0)