From afefef96757b15126e271fbeee7e351c06ac5047 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 6 Oct 2026 22:35:20 +0100 Subject: [PATCH] Enforce 85 percent Android SDK line coverage --- .ci/coverage-thresholds.json | 3 ++ .github/workflows/android-test.yml | 4 ++- .github/workflows/ci.yml | 5 +++ dev.yml | 2 ++ scripts/check_coverage | 12 +++++++ scripts/lib/coverage_thresholds.rb | 24 ++++++++++++++ scripts/test/coverage_thresholds_test.rb | 40 ++++++++++++++++++++++++ 7 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 .ci/coverage-thresholds.json create mode 100755 scripts/check_coverage create mode 100644 scripts/lib/coverage_thresholds.rb create mode 100644 scripts/test/coverage_thresholds_test.rb diff --git a/.ci/coverage-thresholds.json b/.ci/coverage-thresholds.json new file mode 100644 index 000000000..13a6ed2d2 --- /dev/null +++ b/.ci/coverage-thresholds.json @@ -0,0 +1,3 @@ +{ + "android": {"Lines": 85} +} diff --git a/.github/workflows/android-test.yml b/.github/workflows/android-test.yml index a6dc59064..6a0c6a4ee 100644 --- a/.github/workflows/android-test.yml +++ b/.github/workflows/android-test.yml @@ -34,7 +34,9 @@ jobs: - name: Run tests and generate library coverage id: coverage_tests - run: ./gradlew test :lib:createDebugUnitTestCoverageReport --console=plain + run: | + ./gradlew test :lib:createDebugUnitTestCoverageReport --console=plain + ruby ../../scripts/check_coverage android lib/build/reports/coverage/test/debug/report.xml - name: Record coverage if: ${{ !cancelled() }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c750216a0..9d935bc7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: with: filters: | android: + - '.ci/coverage-thresholds.json' + - 'scripts/check_coverage' + - 'scripts/lib/coverage_thresholds.rb' - '.github/actions/report-coverage/**' - 'scripts/report_coverage' - 'scripts/lib/coverage_report.rb' @@ -114,6 +117,8 @@ jobs: - '.ci/changed-file-filters.yml' - '.github/workflows/ci.yml' scripts: + - '.ci/coverage-thresholds.json' + - 'scripts/check_coverage' - '.github/scripts/*coverage*' - '.github/workflows/coverage-report.yml' - '.github/actions/report-coverage/**' diff --git a/dev.yml b/dev.yml index 9a4be6dd3..9ab7f6013 100644 --- a/dev.yml +++ b/dev.yml @@ -394,7 +394,9 @@ commands: run: | source platforms/android/scripts/parse_local_protocol_flag parse_local_protocol_flag "$@" + set -e platforms/android/gradlew -p platforms/android :lib:createDebugUnitTestCoverageReport --console=plain + ruby scripts/check_coverage android platforms/android/lib/build/reports/coverage/test/debug/report.xml samples: desc: Run JVM tests for the Checkout Kit Android sample app syntax: diff --git a/scripts/check_coverage b/scripts/check_coverage new file mode 100755 index 000000000..813839418 --- /dev/null +++ b/scripts/check_coverage @@ -0,0 +1,12 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "lib/coverage_report" +require_relative "lib/coverage_thresholds" + +abort "Usage: ruby scripts/check_coverage " unless ARGV.length == 2 +report = CoverageReport.new(ARGV[0], File.read(ARGV[1])) +configuration = JSON.parse(File.read(File.expand_path("../.ci/coverage-thresholds.json", __dir__))) +failures = CoverageThresholds.new(configuration).failures(report) +abort "Coverage threshold failed for #{CoverageReport::TITLES.fetch(report.platform)}:\n#{failures.join("\n")}" unless failures.empty? +puts "Coverage thresholds passed for #{CoverageReport::TITLES.fetch(report.platform)}." diff --git a/scripts/lib/coverage_thresholds.rb b/scripts/lib/coverage_thresholds.rb new file mode 100644 index 000000000..219198e43 --- /dev/null +++ b/scripts/lib/coverage_thresholds.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "json" + +class CoverageThresholds + def initialize(configuration) + @configuration = configuration + end + + def failures(report) + @configuration.fetch(report.platform).filter_map do |metric, minimum| + raise "Invalid threshold for #{metric}" unless minimum.is_a?(Numeric) && minimum.finite? && minimum.between?(0, 100) + + row = report.rows.find { |entry| entry[0] == metric } + next "#{metric}: coverage is missing (minimum #{minimum}%)" unless row + + _, covered, total = row + next "#{metric}: no executable code was measured (minimum #{minimum}%)" if total.zero? + next if covered * 100 >= minimum * total + + "#{metric}: #{format('%.2f', 100.0 * covered / total)}% (#{covered}/#{total}) is below #{minimum}%" + end + end +end diff --git a/scripts/test/coverage_thresholds_test.rb b/scripts/test/coverage_thresholds_test.rb new file mode 100644 index 000000000..a28490e2b --- /dev/null +++ b/scripts/test/coverage_thresholds_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "../lib/coverage_thresholds" + +class CoverageThresholdsTest < Minitest::Test + Report = Struct.new(:platform, :rows) + + def threshold_failures(rows, configuration = {"android" => {"Lines" => 85}}) + CoverageThresholds.new(configuration).failures(Report.new("android", rows)) + end + + def test_exact_threshold_passes_but_rounding_cannot_hide_a_failure + assert_empty threshold_failures([["Lines", 85, 100]]) + assert_equal 1, threshold_failures([["Lines", 849999, 1000000]]).length + assert_empty threshold_failures([["Lines", 86, 100]]) + end + + def test_missing_or_uninstrumented_code_does_not_pass + assert_match(/missing/, threshold_failures([]).first) + assert_match(/no executable code/, threshold_failures([["Lines", 0, 0]]).first) + end + + def test_unconfigured_metrics_remain_report_only + assert_empty threshold_failures([["Lines", 90, 100], ["Branches", 1, 100]]) + end + + def test_every_configured_target_must_pass_independently + configuration = {"android" => {"First" => 85, "Second" => 85}} + result = threshold_failures([["First", 99, 100], ["Second", 70, 100]], configuration) + assert_equal ["Second: 70.00% (70/100) is below 85%"], result + end + + def test_invalid_configuration_fails_closed + assert_raises(KeyError) { threshold_failures([], {}) } + [-1, 101, "85", Float::NAN].each do |minimum| + assert_raises(RuntimeError) { threshold_failures([["Lines", 90, 100]], {"android" => {"Lines" => minimum}}) } + end + end +end