Skip to content

Commit

Permalink
Fix part of #5343: Generate Code Coverage Report in HTML and MARKDOWN…
Browse files Browse the repository at this point in the history
… formats (#5443)

<!-- READ ME FIRST: Please fill in the explanation section below and
check off every point from the Essential Checklist! -->
## Explanation
<!--
- Explain what your PR does. If this PR fixes an existing bug, please
include
- "Fixes #bugnum:" in the explanation so that GitHub can auto-close the
issue
  - when this PR is merged.
  -->

Fixes part of #5343 

### Project 
[PR 1.5 of Project 4.1]

### Changes Made
This PR introduces the `CoverageReporter` utility to generate the code
coverage report. The main features and changes include:

- **New Utility:**
  - `CoverageReporter` to generate code coverage reports.

- **Command Line Arguments:**
  - Now support output formats: `HTML` or `MARKDOWN`.

- **Report Generation:**
- Generates coverage reports from the generated proto for the requested
format.
- Outputs are generated and stored in the `coverage_reports` folder
relative to the `repoRoot`.

- **Gitignore Update:**
- The `coverage_reports` folder is added to `.gitignore` to ensure it is
not added to the repository.


## Essential Checklist
<!-- Please tick the relevant boxes by putting an "x" in them. -->
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).

## For UI-specific PRs only
<!-- Delete these section if this PR does not include UI-related
changes. -->
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- For PRs introducing new UI elements or color changes, both light and
dark mode screenshots must be included
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing

---------

Co-authored-by: Ben Henning <ben@oppia.org>
  • Loading branch information
Rd4dev and BenHenning committed Jun 28, 2024
1 parent c5de68b commit f53c7e5
Show file tree
Hide file tree
Showing 9 changed files with 2,230 additions and 271 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ bazel-*
.bazelproject
.aswb
*.pb
coverage_reports
14 changes: 14 additions & 0 deletions scripts/src/java/org/oppia/android/scripts/coverage/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ kt_jvm_library(
],
visibility = ["//scripts:oppia_script_binary_visibility"],
deps = [
":coverage_reporter",
":coverage_runner",
"//scripts/src/java/org/oppia/android/scripts/common:bazel_client",
"//scripts/src/java/org/oppia/android/scripts/proto:script_exemptions_java_proto",
Expand All @@ -30,3 +31,16 @@ kt_jvm_library(
"//scripts/src/java/org/oppia/android/scripts/proto:coverage_java_proto",
],
)

kt_jvm_library(
name = "coverage_reporter",
testonly = True,
srcs = [
"CoverageReporter.kt",
],
visibility = ["//scripts:oppia_script_binary_visibility"],
deps = [
"//scripts/src/java/org/oppia/android/scripts/common:bazel_client",
"//scripts/src/java/org/oppia/android/scripts/proto:coverage_java_proto",
],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package org.oppia.android.scripts.coverage

import org.oppia.android.scripts.proto.Coverage
import org.oppia.android.scripts.proto.CoverageReport
import java.io.File

/**
* Class responsible for generating rich text coverage report.
*
* @param repoRoot the root directory of the repository
* @param coverageReportList the list of coverage data proto
* @param reportFormat the format in which the report will be generated
*/
class CoverageReporter(
private val repoRoot: String,
private val coverageReportList: List<CoverageReport>,
private val reportFormat: ReportFormat,
) {
private val computedCoverageRatio = computeCoverageRatio()
private val formattedCoveragePercentage = "%.2f".format(computedCoverageRatio * 100)

private val filePath = coverageReportList.firstOrNull()?.filePath ?: "Unknown"

private val totalLinesFound = coverageReportList.getOrNull(0)?.linesFound ?: 0
private val totalLinesHit = coverageReportList.getOrNull(0)?.linesHit ?: 0

/**
* Generates a rich text report for the analysed coverage data based on the specified format.
* It supports Markdown and HTML formats.
*
* @return a pair where the first value is the computed coverage ratio represented in [0, 1]
* and the second value is the generated report text
*/
fun generateRichTextReport(): Pair<Float, String> {
println("report format: $reportFormat")
return when (reportFormat) {
ReportFormat.MARKDOWN -> generateMarkdownReport()
ReportFormat.HTML -> generateHtmlReport()
}
}

private fun generateMarkdownReport(): Pair<Float, String> {
val markdownContent =
"""
## Coverage Report
- **Covered File:** $filePath
- **Coverage percentage:** $formattedCoveragePercentage% covered
- **Line coverage:** $totalLinesHit / $totalLinesFound lines covered
""".trimIndent()

println("\n$markdownContent")

return Pair(computedCoverageRatio, markdownContent)
}

private fun generateHtmlReport(): Pair<Float, String> {
var htmlContent =
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coverage Report</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
padding: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th, td {
padding: 8px;
margin-left: 20px;
text-align: left;
border-bottom: 1px solid #fdfdfd;
}
.line-number-col {
width: 2%;
}
.line-number-row {
border-right: 1px dashed #000000
}
.source-code-col {
width: 98%;
}
.covered-line, .not-covered-line, .uncovered-line {
white-space: pre-wrap;
word-wrap: break-word;
box-sizing: border-box;
border-radius: 4px;
padding: 2px 8px 2px 4px;
display: inline-block;
}
.covered-line {
background-color: #c8e6c9; /* Light green */
}
.not-covered-line {
background-color: #ffcdd2; /* Light red */
}
.uncovered-line {
background-color: #f1f1f1; /* light gray */
}
.coverage-summary {
margin-bottom: 20px;
}
h2 {
text-align: center;
}
ul {
list-style-type: none;
padding: 0;
text-align: center;
}
.summary-box {
background-color: #f0f0f0;
border: 1px solid #ccc;
border-radius: 8px;
padding: 10px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.summary-left {
text-align: left;
}
.summary-right {
text-align: right;
}
.legend {
display: flex;
align-items: center;
}
.legend-item {
width: 20px;
height: 10px;
margin-right: 5px;
border-radius: 2px;
display: inline-block;
}
.legend .covered {
background-color: #c8e6c9; /* Light green */
}
.legend .not-covered {
margin-left: 4px;
background-color: #ffcdd2; /* Light red */
}
@media screen and (max-width: 768px) {
body {
padding: 10px;
}
table {
width: auto;
}
}
</style>
</head>
<body>
<h2>Coverage Report</h2>
<div class="summary-box">
<div class="summary-left">
<strong>Covered File:</strong> $filePath <br>
<div class="legend">
<div class="legend-item covered"></div>
<span>Covered</span>
<div class="legend-item not-covered"></div>
<span>Uncovered</span>
</div>
</div>
<div class="summary-right">
<div><strong>Coverage percentage:</strong> $formattedCoveragePercentage%</div>
<div><strong>Line coverage:</strong> $totalLinesHit / $totalLinesFound covered</div>
</div>
</div>
<table>
<thead>
<tr>
<th class="line-number-col">Line No</th>
<th class="source-code-col">Source Code</th>
</tr>
</thead>
<tbody>
""".trimIndent()

val fileContent = File(repoRoot, filePath).readLines()
val coverageMap = coverageReportList
.firstOrNull()?.coveredLineList?.associateBy { it.lineNumber }

fileContent.forEachIndexed { index, line ->
val lineNumber = index + 1
val lineClass = when (coverageMap?.get(lineNumber)?.coverage) {
Coverage.FULL -> "covered-line"
Coverage.NONE -> "not-covered-line"
else -> "uncovered-line"
}
htmlContent += """
<tr>
<td class="line-number-row">${lineNumber.toString().padStart(4, ' ')}</td>
<td class="$lineClass">$line</td>
</tr>
""".trimIndent()
}

htmlContent += """
</tbody>
</table>
</body>
</html>
""".trimIndent()

return Pair(computedCoverageRatio, htmlContent)
}

private fun computeCoverageRatio(): Float {
val report = coverageReportList.getOrNull(0)
return if (report != null && report.linesFound != 0) {
report.linesHit.toFloat() / report.linesFound.toFloat()
} else {
0f
}
}
}

/** Represents the different types of formats available to generate code coverage reports. */
enum class ReportFormat {
/** Indicates that the report should be formatted in .md format. */
MARKDOWN,
/** Indicates that the report should be formatted in .html format. */
HTML
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ class CoverageRunner(
}

private fun extractTargetName(bazelTestTarget: String): String {
val targetName = bazelTestTarget.substringAfterLast(":").trim()
val targetName = bazelTestTarget
.substringAfterLast("/")
.substringAfterLast(":")
.trim()
return targetName.removeSuffix("LocalTest").removeSuffix("Test")
}

Expand Down
60 changes: 54 additions & 6 deletions scripts/src/java/org/oppia/android/scripts/coverage/RunCoverage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import java.util.concurrent.TimeUnit
* Arguments:
* - path_to_root: directory path to the root of the Oppia Android repository.
* - relative_path_to_file: the relative path to the file to analyse coverage
* - reportFormat: the format of the coverage report. Defaults to MARKDOWN if not specified.
* Available options: MARKDOWN, HTML.
*
* Example:
* bazel run //scripts:run_coverage -- $(pwd)
* utility/src/main/java/org/oppia/android/util/parser/math/MathModel.kt
* utility/src/main/java/org/oppia/android/util/parser/math/MathModel.kt HTML
* Example with custom process timeout:
* bazel run //scripts:run_coverage -- $(pwd)
* utility/src/main/java/org/oppia/android/util/parser/math/MathModel.kt processTimeout=10
Expand All @@ -31,6 +33,14 @@ import java.util.concurrent.TimeUnit
fun main(vararg args: String) {
val repoRoot = args[0]
val filePath = args[1]
val format = args.getOrNull(2)
val reportFormat = when {
format.equals("HTML", ignoreCase = true) -> ReportFormat.HTML
format.equals("MARKDOWN", ignoreCase = true) || format == null -> ReportFormat.MARKDOWN
else -> throw IllegalArgumentException("Unsupported report format: $format")
}

val reportOutputPath = getReportOutputPath(repoRoot, filePath, reportFormat)

if (!File(repoRoot, filePath).exists()) {
error("File doesn't exist: $filePath.")
Expand All @@ -45,7 +55,14 @@ fun main(vararg args: String) {
scriptBgDispatcher, processTimeout = processTimeout, processTimeoutUnit = TimeUnit.MINUTES
)

RunCoverage(repoRoot, filePath, commandExecutor, scriptBgDispatcher).execute()
RunCoverage(
repoRoot,
filePath,
reportFormat,
reportOutputPath,
commandExecutor,
scriptBgDispatcher
).execute()
}
}

Expand All @@ -60,6 +77,8 @@ fun main(vararg args: String) {
class RunCoverage(
private val repoRoot: String,
private val filePath: String,
private val reportFormat: ReportFormat,
private val reportOutputPath: String,
private val commandExecutor: CommandExecutor,
private val scriptBgDispatcher: ScriptBackgroundCoroutineDispatcher
) {
Expand All @@ -79,15 +98,14 @@ class RunCoverage(
* @return a list of lists containing coverage data for each requested test target, if
* the file is exempted from having a test file, an empty list is returned
*/
fun execute(): List<CoverageReport> {
fun execute(): String {
val testFileExemptionList = loadTestFileExemptionsProto(testFileExemptionTextProto)
.testFileExemptionList
.filter { it.testFileNotRequired }
.map { it.exemptedFilePath }

if (filePath in testFileExemptionList) {
println("This file is exempted from having a test file; skipping coverage check.")
return emptyList()
return "This file is exempted from having a test file; skipping coverage check."
}

val testFilePaths = findTestFile(repoRoot, filePath)
Expand All @@ -97,9 +115,26 @@ class RunCoverage(

val testTargets = bazelClient.retrieveBazelTargets(testFilePaths)

return testTargets.mapNotNull { testTarget ->
val coverageReports = testTargets.mapNotNull { testTarget ->
runCoverageForTarget(testTarget)
}

coverageReports.takeIf { it.isNotEmpty() }?.run {
val reporter = CoverageReporter(repoRoot, this, reportFormat)
val (computedCoverageRatio, reportText) = reporter.generateRichTextReport()

File(reportOutputPath).apply {
parentFile?.mkdirs()
writeText(reportText)
}

if (File(reportOutputPath).exists()) {
println("\nComputed Coverage Ratio is: $computedCoverageRatio")
println("\nGenerated report at: $reportOutputPath\n")
}
} ?: println("No coverage reports generated.")

return reportOutputPath
}

private fun runCoverageForTarget(testTarget: String): CoverageReport {
Expand Down Expand Up @@ -136,6 +171,19 @@ private fun findTestFile(repoRoot: String, filePath: String): List<String> {
.map { it.relativeTo(repoRootFile).path }
}

private fun getReportOutputPath(
repoRoot: String,
filePath: String,
reportFormat: ReportFormat
): String {
val fileWithoutExtension = filePath.substringBeforeLast(".")
val defaultFilename = when (reportFormat) {
ReportFormat.HTML -> "coverage.html"
ReportFormat.MARKDOWN -> "coverage.md"
}
return "$repoRoot/coverage_reports/$fileWithoutExtension/$defaultFilename"
}

private fun loadTestFileExemptionsProto(testFileExemptiontextProto: String): TestFileExemptions {
return File("$testFileExemptiontextProto.pb").inputStream().use { stream ->
TestFileExemptions.newBuilder().also { builder ->
Expand Down
Loading

0 comments on commit f53c7e5

Please sign in to comment.