File: experiments_test_coverage.rb

package info (click to toggle)
gitlab 17.6.5-19
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 629,368 kB
  • sloc: ruby: 1,915,304; javascript: 557,307; sql: 60,639; xml: 6,509; sh: 4,567; makefile: 1,239; python: 406
file content (110 lines) | stat: -rw-r--r-- 3,387 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# 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)
        test_file_path = filepath(node).gsub('app/', 'spec/').gsub('.rb', '_spec.rb')
        test_file_path << '_spec.rb' if test_file_path.end_with?('.haml')

        "#{read_file(test_file_path)}\n#{additional_tests_code(test_file_path)}"
      end

      def additional_tests_code(test_file_path)
        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
      end

      def read_file(file_path)
        File.exist?(file_path) ? File.read(file_path) : ''
      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