Add a message when coverage exceeds minimum.
Add a message when coverage exceeds minimum.

This way we'll be sure to continually raise the minimum threshold as we exceed it.

Reviewed at https://reviews.apache.org/r/26902/

/**
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.tasks.TaskAction

/**
 * A task that analyzes the XML coverage report from JaCoCo and fails the build if code coverage
 * metrics are not met.
 */
class CoverageReportCheck extends DefaultTask {

  // The XML coverage report file.
  def coverageReportFile

  // Minimum ratio of instructions covered, [0, 1.0].
  def minInstructionCoverage

  // Minimum ratio of branches covered, [0, 1.0].
  def minBranchCoverage

  // Classes that may be allowed to have zero test coverage.
  def legacyClassesWithoutCoverage

  private def computeCoverage(counterNodes, type) {
    def node = counterNodes.find { it.@type == type }
    def missed = node.@missed.toInteger()
    def covered = node.@covered.toInteger()
    return ((double) covered) / (missed + covered)
  }

  def checkThresholds(coverage, minCoverage, type) {
    if (coverage < minCoverage) {
      return "$type coverage must be greater than $minCoverage"
    } else {
      def floored = Math.floor(coverage * 100) / 100
      if (floored > minCoverage) {
        return("$type coverage of $floored exceeds min instruction coverage of $minCoverage,"
            + " please raise the threshold!")
      }
    }
  }

  def checkGlobalCoverage(coverageCounts) {
    def coverageErrors = [
        [computeCoverage(coverageCounts, 'INSTRUCTION'), minInstructionCoverage, 'Instruction'],
        [computeCoverage(coverageCounts, 'BRANCH'), minBranchCoverage, 'Branch']
    ].collect() {
      return checkThresholds(*it)
    }.findAll()

    if (!coverageErrors.isEmpty()) {
      throw new GradleException(coverageErrors.join('\n'))
    }
  }

  def checkClassCoverage(coverageReport) {
    def coverageErrors = coverageReport.package.class.collect { cls ->
      // javac inserts a synthetic constructor for anonymous classes, and jacoco tends to mark
      // these as covered in some cases, leading to flaky behavior.  We work around that by
      // collecting the coverage count for each method in each class, omitting the default
      // constructor for anonymous classes.  Anonymous classes are identified by their name,
      // which we expect to be of the form 'Something$1'.  Jacoco names default constructors
      // '<init>', so we filter based on that.
      def methodFilter = cls.@name ==~ /.*\$\d+/ ? { m -> m.@name != '<init>' } : { true }

      def covered = cls.method.findAll(methodFilter).collect { m ->
        m.counter.find({ c -> c.@type == 'INSTRUCTION' }).@covered.toInteger()}.sum(0)

      if (cls.@name in legacyClassesWithoutCoverage) {
        if (covered != 0) {
          return 'Thanks for adding the first test coverage to: ' + cls.@name \
              + ' please remove it from the legacyClassesWithoutCoverage list'
        }
      } else if (covered == 0) {
        return 'Test coverage missing for ' + cls.@name
      }
      return null
    }.findAll()  // Filter nulls.
    if (!coverageErrors.isEmpty()) {
      throw new GradleException(coverageErrors.join('\n'))
    }
  }

  @TaskAction
  def analyze() {
    def parser = new XmlSlurper()
    parser.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false);
    // Avoid trying to load the DTD for the XML document, which does not exist.
    parser.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)

    def coverageReport = parser.parse(coverageReportFile)

    checkGlobalCoverage(coverageReport.counter)
    checkClassCoverage(coverageReport)
  }
}

/**
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.tasks.TaskAction

/**
 * A task that analyzes the XML coverage report from JaCoCo and fails the build if code coverage
 * metrics are not met.
 */
class CoverageReportCheck extends DefaultTask {

  // The XML coverage report file.
  def coverageReportFile

  // Minimum ratio of instructions covered, [0, 1.0].
  def minInstructionCoverage

  // Minimum ratio of branches covered, [0, 1.0].
  def minBranchCoverage

  // Classes that may be allowed to have zero test coverage.
  def legacyClassesWithoutCoverage

  private def computeCoverage(counterNodes, type) {
    def node = counterNodes.find { it.@type == type }
    def missed = node.@missed.toInteger()
    def covered = node.@covered.toInteger()
    return ((double) covered) / (missed + covered)
  }

  @TaskAction
  def analyze() {
    def parser = new XmlSlurper()
    parser.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false);
    // Avoid trying to load the DTD for the XML document, which does not exist.
    parser.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)

    def coverageReport = parser.parse(coverageReportFile)

    // Check global thresholds.
    if (computeCoverage(coverageReport.counter, 'INSTRUCTION') < minInstructionCoverage) {
      throw new GradleException(
          'Instruction coverage must be greater than ' + minInstructionCoverage)
    }

    if (computeCoverage(coverageReport.counter, 'BRANCH') < minBranchCoverage) {
      throw new GradleException('Branch coverage must be greater than ' + minBranchCoverage)
    }

    def coverageErrors = coverageReport.package.class.collect { cls ->
        // javac inserts a synthetic constructor for anonymous classes, and jacoco tends to mark
        // these as covered in some cases, leading to flaky behavior.  We work around that by
        // collecting the coverage count for each method in each class, omitting the default
        // constructor for anonymous classes.  Anonymous classes are identified by their name,
        // which we expect to be of the form 'Something$1'.  Jacoco names default constructors
        // '<init>', so we filter based on that.
        def methodFilter = cls.@name ==~ /.*\$\d+/ ? { m -> m.@name != '<init>' } : { true }

        def covered = cls.method.findAll(methodFilter).collect { m ->
          m.counter.find({ c -> c.@type == 'INSTRUCTION' }).@covered.toInteger()}.sum(0)

        if (cls.@name in legacyClassesWithoutCoverage) {
          if (covered != 0) {
            return 'Thanks for adding the first test coverage to: ' + cls.@name \
              + ' please remove it from the legacyClassesWithoutCoverage list'
          }
        } else if (covered == 0) {
          return 'Test coverage missing for ' + cls.@name
        }
        return null
    }.findAll()  // Filter nulls.
    if (!coverageErrors.isEmpty()) {
      throw new GradleException(coverageErrors.join('\n'))
    }
  }
}
