# frozen_string_literal: true require 'jacoco/sax_parser' module Danger # Verify code coverage inside your projects # This is done using the jacoco output # Results are passed out as a table in markdown # # @example Verify coverage # jacoco.minimum_project_coverage_percentage = 50 # # @example Verify coverage per package # jacoco.minimum_package_coverage_map = { # optional (default is empty) # 'com/package/' => 55, # 'com/package/more/specific/' => 15 # } # # @see Anton Malinskiy/danger-jacoco # @tags jacoco, coverage, java, android, kotlin # class DangerJacoco < Plugin # rubocop:disable Metrics/ClassLength attr_accessor :minimum_project_coverage_percentage, :minimum_class_coverage_percentage, :minimum_composable_class_coverage_percentage, :files_to_check, :files_extension, :minimum_package_coverage_map, :minimum_class_coverage_map, :fail_no_coverage_data_found, :title, :class_column_title, :subtitle_success, :subtitle_failure, :file_to_create_on_failure # Initialize the plugin with configured parameters or defaults def setup setup_minimum_coverages setup_texts @files_to_check = [] unless files_to_check @files_extension = ['.kt', '.java'] unless files_extension @file_to_create_on_failure = 'danger_jacoco_failure_status_file.txt' unless file_to_create_on_failure end # Initialize the plugin with configured optional texts def setup_texts @title = 'JaCoCo' unless title @class_column_title = 'Class' unless class_column_title @subtitle_success = 'All classes meet coverage requirement. Well done! :white_check_mark:' unless subtitle_success @subtitle_failure = 'There are classes that do not meet coverage requirement :warning:' unless subtitle_failure end # Initialize the plugin with configured coverage minimum parameters or defaults def setup_minimum_coverages @minimum_project_coverage_percentage = 0 unless minimum_project_coverage_percentage @minimum_class_coverage_percentage = 0 unless minimum_class_coverage_percentage @minimum_composable_class_coverage_percentage = 0 unless minimum_composable_class_coverage_percentage @minimum_package_coverage_map = {} unless minimum_package_coverage_map @minimum_class_coverage_map = {} unless minimum_class_coverage_map end # Parses the xml output of jacoco to Ruby model classes # This is slow since it's basically DOM parsing # # @path path to the xml output of jacoco # def parse(path) Jacoco::DOMParser.read_path(path) end # This is a fast report based on SAX parser # # @path path to the xml output of jacoco # @report_url URL where html report hosted # @delimiter git.modified_files returns full paths to the # changed files. We need to get the class from this path to check the # Jacoco report, # # e.g. src/java/com/example/SomeJavaClass.java -> com/example/SomeJavaClass # e.g. src/kotlin/com/example/SomeKotlinClass.kt -> com/example/SomeKotlinClass # # The default value supposes that you're using gradle structure, # that is your path to source files is something like # # Java => blah/blah/java/slashed_package/Source.java # Kotlin => blah/blah/kotlin/slashed_package/Source.kt # # rubocop:disable Style/AbcSize def report(path, report_url = '', delimiter = %r{/java/|/kotlin/}, fail_no_coverage_data_found: true) @fail_no_coverage_data_found = fail_no_coverage_data_found setup class_to_file_path_hash = classes(delimiter) classnames = class_to_file_path_hash.keys parser = Jacoco::SAXParser.new(classnames) Nokogiri::XML::SAX::Parser.new(parser).parse(File.open(path)) total_covered = total_coverage(path) header = "### #{title} Code Coverage #{total_covered[:covered]}% #{total_covered[:status]}\n" report_markdown = header report_markdown += "| #{class_column_title} | Covered | Required | Status |\n" report_markdown += "|:---|:---:|:---:|:---:|\n" class_coverage_above_minimum = markdown_class(parser, report_markdown, report_url, class_to_file_path_hash) subtitle = class_coverage_above_minimum ? subtitle_success : subtitle_failure report_markdown.insert(header.length, "#### #{subtitle}\n") markdown(report_markdown) report_fails(class_coverage_above_minimum, total_covered) end # rubocop:enable Style/AbcSize def classes(delimiter) class_to_file_path_hash = {} files_to_check.select { |file| files_extension.reduce(false) { |state, el| state || file.end_with?(el) } } .each do |file| # "src/java/com/example/CachedRepository.java" classname = file.split('.').first.split(delimiter)[1] # "com/example/CachedRepository" class_to_file_path_hash[classname] = file end class_to_file_path_hash end # It returns a specific class code coverage and an emoji status as well def report_class(jacoco_class, file_path) report_result = { covered: 'No coverage data found : -', status: ':black_joker:', required_coverage_percentage: 'No coverage data found : -' } counter = coverage_counter(jacoco_class) unless counter.nil? coverage = (counter.covered.fdiv(counter.covered + counter.missed) * 100).floor required_coverage = required_class_coverage(jacoco_class, file_path) status = coverage_status(coverage, required_coverage) report_result = { covered: coverage, status: status, required_coverage_percentage: required_coverage } end report_result end # Determines the required coverage for the class # rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/CyclomaticComplexity def required_class_coverage(jacoco_class, file_path) key = minimum_class_coverage_map.keys.detect { |k| jacoco_class.name.match(k) } || jacoco_class.name required_coverage = minimum_class_coverage_map[key] includes_composables = File.read(file_path).include? '@Composable' if File.exist?(file_path) required_coverage = minimum_composable_class_coverage_percentage if required_coverage.nil? && includes_composables required_coverage = package_coverage(jacoco_class.name) if required_coverage.nil? required_coverage = minimum_class_coverage_percentage if required_coverage.nil? required_coverage end # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/CyclomaticComplexity # it returns the most suitable coverage by package name to class or nil def package_coverage(class_name) path = class_name package_parts = class_name.split('/') package_parts.reverse_each do |item| size = item.size path = path[0...-size] coverage = minimum_package_coverage_map[path] path = path[0...-1] unless path.empty? return coverage unless coverage.nil? end nil end # it returns an emoji for coverage status def coverage_status(coverage, minimum_percentage) if coverage < (minimum_percentage / 2) then ':skull:' elsif coverage < minimum_percentage then ':warning:' else ':white_check_mark:' end end # It returns total of project code coverage and an emoji status as well def total_coverage(report_path) jacoco_report = Nokogiri::XML(File.open(report_path)) report = jacoco_report.xpath('report/counter').select { |item| item['type'] == 'INSTRUCTION' } missed_instructions = report.first['missed'].to_f covered_instructions = report.first['covered'].to_f total_instructions = missed_instructions + covered_instructions covered_percentage = (covered_instructions * 100 / total_instructions).round(2) coverage_status = coverage_status(covered_percentage, minimum_project_coverage_percentage) { covered: covered_percentage, status: coverage_status } end private def coverage_counter(jacoco_class) all_class_counters = jacoco_class.counters counter = class_counter(all_class_counters) if counter.nil? no_coverage_data_found_message = "No coverage data found for #{jacoco_class.name}" raise no_coverage_data_found_message if @fail_no_coverage_data_found.instance_of?(TrueClass) warn no_coverage_data_found_message end counter end def class_counter(all_class_counters) instruction_counter = all_class_counters.detect { |e| e.type.eql? 'INSTRUCTION' } branch_counter = all_class_counters.detect { |e| e.type.eql? 'BRANCH' } line_counter = all_class_counters.detect { |e| e.type.eql? 'LINE' } if !instruction_counter.nil? instruction_counter elsif !branch_counter.nil? branch_counter else line_counter end end # rubocop:disable Style/SignalException def report_fails(class_coverage_above_minimum, total_covered) if total_covered[:covered] < minimum_project_coverage_percentage # fail danger if total coverage is smaller than minimum_project_coverage_percentage covered = total_covered[:covered] fail("Total coverage of #{covered}%. Improve this to at least #{minimum_project_coverage_percentage}%") # rubocop:disable Lint/UnreachableCode (rubocop mistakenly thinks that this line is unreachable since priorly called "fail" raises an error, but in fact "fail" is caught and handled) create_status_file_on_failure if class_coverage_above_minimum # rubocop:enable Lint/UnreachableCode end return if class_coverage_above_minimum fail("Class coverage is below minimum. Improve to at least #{minimum_class_coverage_percentage}%") # rubocop:disable Lint/UnreachableCode (rubocop mistakenly thinks that this line is unreachable since priorly called "fail" raises an error, but in fact "fail" is caught and handled) create_status_file_on_failure # rubocop:enable Lint/UnreachableCode end # rubocop:enable Style/SignalException def create_status_file_on_failure File.open(file_to_create_on_failure, 'w') {} end def markdown_class(parser, report_markdown, report_url, class_to_file_path_hash) class_coverage_above_minimum = true parser.classes.each do |jacoco_class| # Check metrics for each classes file_path = class_to_file_path_hash[jacoco_class.name] rp = report_class(jacoco_class, file_path) rl = report_link(jacoco_class.name, report_url) ln = "| #{rl} | #{rp[:covered]}% | #{rp[:required_coverage_percentage]}% | #{rp[:status]} |\n" report_markdown << ln class_coverage_above_minimum &&= rp[:covered] >= rp[:required_coverage_percentage] end class_coverage_above_minimum end def report_link(class_name, report_url) if report_url.empty? "`#{class_name}`" else report_filepath = "#{class_name.gsub(%r{/(?=[^/]*/.)}, '.')}.html" "[`#{class_name}`](#{report_url + report_filepath})" end end end end