Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

build: DH-18379: Java Code Coverage #6576

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
id 'base'
id 'io.deephaven.project.register'
id 'jacoco-report-aggregation'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused... is this plugin actually being used?

}

import org.gradle.internal.jvm.Jvm
Expand Down Expand Up @@ -84,6 +85,35 @@ tasks.register('smoke') {
it.dependsOn project(':Generators').tasks.findByName(LifecycleBasePlugin.CHECK_TASK_NAME)
}

tasks.register("coverage") {
System.setProperty "coverageEnabled", "true"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not be configuring system properties based on when this task is executed... we should be using gradle properties for parts of the build system we want configurable.

allprojects.findAll { p-> p.plugins.hasPlugin('java') }.each {
it.apply(['from':rootProject.file("coverage/jacoco.gradle")])
}
}

tasks.register("coverage-merge", JacocoReport) {
def jprojects = allprojects.findAll { p-> p.plugins.hasPlugin('java') }
additionalSourceDirs = files(jprojects.sourceSets.main.allSource.srcDirs)
sourceDirectories = files(jprojects.sourceSets.main.allSource.srcDirs)
classDirectories = files(jprojects.sourceSets.main.output)
reports {
html.required = true
csv.required = true
xml.required = false
}
def projRootDir = project.rootDir.absolutePath
executionData fileTree(projRootDir).include("**/build/jacoco/*.exec")
doLast {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is probably bad to attach a doLast to an existing Task type; likely, you'll want a separate task to depend on the output of JacocoReport.

def stdout = new StringBuilder(), stderr = new StringBuilder()
def task = ('python ' + projRootDir + '/coverage/all-coverage.py ' + projRootDir).execute()
task.consumeProcessOutput(stdout, stderr)
task.waitFor()
println(stdout)
println(stderr)
Comment on lines +112 to +113
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here on purpose?

}
}
Comment on lines +90 to +115
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not be building in this logic at the root build.gradle level; we have buildSrc plugins to do this.


tasks.wrapper {
Wrapper w ->
w.distributionType = 'ALL'
Expand Down
46 changes: 46 additions & 0 deletions coverage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Overview

This project is a collection of gradle builds and scripts for running and gathering code coverage over projects using different languages. The Gradle workflow for this allows project coverage to be run optionally. Since coverage is a separate step, the "check" task runs normally with no instrumentation. This tool is intended to be run from the top down and not against individual projects. After "check" runs with coverage turned on, the _coverage-merge_ task can be used to aggregate individual project coverage into the top-level build directory.

## Running for Coverage

A typical run looks like the following that is run from the root of the multi-project build
```
./gradlew coverage check
./gradlew coverage-merge
```
Running the second command is not contingent upon the first command succeeding. It merely collects what coverage is available.

## Result Files

Results for individual project coverage are stored in the project's _build_ output directory. Depending on the language and coverage tools, there will be different result files with slightly different locations and names. For example, Java coverage could produce a binary _jacoco.exec_ file, while python coverage produces a tabbed text file.

Aggregated results produce a merged CSV file for each language under the top-level _build_ directory. Those CSV files are further merged into one _all-coverage.csv_.

## Exclusion Filters

In some cases, there may be a need to exclude some packages from coverage, even though they may be used during testing. For example, some Java classes used in GRPC are generated. The expectation is that the generator mechanism has already been tested and should produce viable classes. Including coverage for those classes in the results as zero coverage causes unnecessary noise and makes it harder to track coverage overall.

To avoid unneeded coverage, the file _exclude-packages.txt_ can be used. This is a list of values to be excluded if they match the "Package" column in the coverage CSV. These are exact values and not wildcards.

## File Layout

Top-level Build Directory (Some languages TBD)
- `coverage` This project's directory
- `java-coverage.py` Gather and normalize coverage for Java projects
- `python-coverage.py` Gather and normalize coverage for Python projects
- `cplus-coverage.py` Gather and normalize coverage for C++ projects
- `r-coverage.py` Gather and normalize coverage for R projects
- `go-coverage.oy` Gather and normalize coverage for Go projects
- `all-coverage.py` Merged all normalized coverage and apply exclusions
- `exclude-packages.txt` A list of packages to exclude from aggregated results
- `build/reports/coverage`
- `java-coverage.csv` Normalized coverage from all Java projects
- `python-coverage.py` Normalized coverage from all Python projects
- `cplus-coverage.py` Normalized coverage from all C++ projects
- `r-coverage.py` Normalized coverage from all R projects
- `go-coverage.oy` Normalized coverage from all Go projects
- `all-coverage.csv` Normalized and filtered coverage from all covered projects
- `build/reports/jacoco/converage-merge/html`
- `index.html` Root file to view Java coverage down to the branch level (not filtered)

43 changes: 43 additions & 0 deletions coverage/all-coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#
# Copyright (c) 2016-2025 Deephaven Data Labs and Patent Pending
#
import sys, glob, csv, os, subprocess

# Aggregate coverage data for all languages. Each language has a different way of doing
# coverage and each normalization mechanism is called here. Class/file exclusions are
# handled here, since coverage tools are inconsistent or non-functional in that regard.

proj_root_dir = sys.argv[1]
script_dir = os.path.dirname(os.path.abspath(__file__))
coverage_dir = proj_root_dir + '/build/reports/coverage'
coverage_output_path = coverage_dir + '/all-coverage.csv'
coverage_input_glob = coverage_dir + '/*-coverage.csv'
exclude_path = script_dir + '/exclude-packages.txt'

if os.path.exists(coverage_output_path):
os.remove(coverage_output_path)

def pycall(lang):
lang_cov = f'{lang}-coverage'
cmd = f'python {script_dir}/{lang_cov}.py {proj_root_dir} {coverage_dir}/{lang_cov}.csv'
result = subprocess.check_output(cmd, shell=True, text=True)
print(result)

# Aggregate and normalize coverage for projects that use each language
pycall('java')
#pycall('python')

# Load packages to be excluded from the aggregated coverage CSV
with open(exclude_path) as f:
excludes = [line.strip() for line in f]

# Collect coverage CSVs into a single CSV without lines containing exclusions
with open(coverage_output_path, 'w', newline='') as outfile:
csv_writer = csv.writer(outfile)
for csv_file in glob.glob(coverage_input_glob):
with open(csv_file, 'r') as csv_in:
for row in csv.reader(csv_in):
if row[2] in excludes: continue
new_row = [row[0],row[1],row[2],row[3],row[4],row[5]]
csv_writer.writerow(new_row)

5 changes: 5 additions & 0 deletions coverage/exclude-packages.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
io.deephaven.tuple.generated
io.deephaven.proto.backplane.grpc
io.deephaven.proto.backplane.script.grpc
io.deephaven.proto

20 changes: 20 additions & 0 deletions coverage/jacoco.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@

if (Boolean.getBoolean("coverageEnabled")) {
apply plugin: 'jacoco'
Comment on lines +2 to +3
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jacoco {
toolVersion = '0.8.12'
}
test {
finalizedBy jacocoTestReport
}
jacocoTestReport {
dependsOn test
reports {
csv.required = true
csv.destination = layout.buildDirectory.file('reports/jacoco/java-coverage.csv').get().asFile
xml.required = false
html.outputLocation = layout.buildDirectory.dir('reports/jacoco/html')
}
}
}

21 changes: 21 additions & 0 deletions coverage/java-coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#
# Copyright (c) 2016-2025 Deephaven Data Labs and Patent Pending
#
import sys, glob, csv

# Convert java coverage CSV files to the normalized form from a multi-project
# root, merge, and write to the given CSV output path

input_glob = sys.argv[1] + '/**/build/reports/jacoco/java-coverage.csv'
output_path = sys.argv[2]

with open(output_path, 'w', newline='') as outfile:
csv_writer = csv.writer(outfile)
csv_writer.writerow(['Language','Project','Package','Class','Missed','Covered'])
for filename in glob.glob(input_glob, recursive = True):
with open(filename, 'r') as csv_in:
csv_reader = csv.reader(csv_in)
next(csv_reader, None)
for row in csv_reader:
new_row = ['java',row[0],row[1],row[2],row[3],row[4]]
csv_writer.writerow(new_row)
Loading