# frozen_string_literal: true module RuboCop module Cop # Check for test coverage for GitLab experiments. class ExperimentsTestCoverage < RuboCop::Cop::Base CLASS_OFFENSE = 'Make sure experiment class has test coverage for all the variants.' BLOCK_OFFENSE = 'Make sure experiment block has test coverage for all the variants.' # Validates classes inherited from ApplicationExperiment # These classes are located under app/experiments or ee/app/experiments def on_class(node) return if node.parent_class&.const_name != 'ApplicationExperiment' return if covered_with_tests?(node) add_offense(node, message: CLASS_OFFENSE) end # Validates experiments block in *.rb and *.haml files: # experiment(:experiment_name) do |e| # e.candidate { 'candidate' } # e.run # end def on_block(node) return if node.method_name != :experiment return if covered_with_tests?(node) add_offense(node, message: BLOCK_OFFENSE) end private def covered_with_tests?(node) tests_code = test_files_code(node) return false if tests_code.blank? return false unless tests_code.match?(stub_experiments_matcher) return false unless tests_code.include?(experiment_name(node)) experiment_variants(node).map { |variant| tests_code.include?(variant) }.all?(&:present?) end def test_files_code(node) # haml-lint add .rb extension to *.haml files # https://gitlab.com/gitlab-org/gitlab/-/issues/415330#caveats test_file_path = filepath(node).gsub('app/', 'spec/').gsub('.rb', '_spec.rb') "#{read_file(test_file_path)}\n#{additional_tests_code(test_file_path)}" end def additional_tests_code(test_file_path) # rubocop:disable Gitlab/NoCodeCoverageComment # :nocov: File paths stubed in tests if test_file_path.include?('/controllers/') read_file(test_file_path.gsub('/controllers/', '/requests/')) elsif test_file_path.include?('/lib/api/') read_file(test_file_path.gsub('/lib/', '/spec/requests/')) end # :nocov: # rubocop:enable Gitlab/NoCodeCoverageComment end def read_file(file_path) File.exist?(file_path) ? File.new(file_path).read : '' end def experiment_name(node) if node.is_a?(RuboCop::AST::ClassNode) File.basename(filepath(node), '_experiment.rb') else block_node_value(node) end end def experiment_variants(node) node.body.children.filter_map do |child| next unless child.is_a?(RuboCop::AST::SendNode) || child.is_a?(RuboCop::AST::BlockNode) extract_variant(child) end end def extract_variant(node) # control enabled by default for tests case node.method_name when :candidate then 'candidate' when :variant then variant_name(node) end end def variant_name(node) return send_node_value(node) if node.is_a?(RuboCop::AST::SendNode) block_node_value(node) end def block_node_value(node) send_node_value(node.children[0]) end def send_node_value(node) node.children[2].value.to_s end def filepath(node) node.location.expression.source_buffer.name end def stub_experiments_matcher # validates test files contains uncommented stub_experiments(... /^([^#]|\s*|\w*)stub_experiments\(/ end end end end