gitlab_quality-test_tooling 3.0.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -4
  3. data/README.md +0 -14
  4. data/exe/test-coverage +51 -8
  5. data/lib/gitlab_quality/test_tooling/code_coverage/README.md +162 -0
  6. data/lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb +5 -2
  7. data/lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb +26 -26
  8. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb +13 -27
  9. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb +3 -41
  10. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb +17 -0
  11. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table.rb +48 -0
  12. data/lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb +71 -34
  13. data/lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb +11 -1
  14. data/lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier.rb +47 -0
  15. data/lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb +46 -0
  16. data/lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data.rb +33 -0
  17. data/lib/gitlab_quality/test_tooling/report/results_in_test_cases.rb +2 -4
  18. data/lib/gitlab_quality/test_tooling/runtime/env.rb +4 -0
  19. data/lib/gitlab_quality/test_tooling/test_meta/processor/meta_processor.rb +1 -1
  20. data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +4 -4
  21. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +8 -0
  22. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +36 -10
  23. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +2 -0
  24. data/lib/gitlab_quality/test_tooling/test_quarantine/quarantine_formatter.rb +38 -0
  25. data/lib/gitlab_quality/test_tooling/test_quarantine/quarantine_helper.rb +76 -0
  26. data/lib/gitlab_quality/test_tooling/test_result/base_test_result.rb +15 -1
  27. data/lib/gitlab_quality/test_tooling/version.rb +1 -1
  28. metadata +9 -28
  29. data/exe/existing-test-health-issue +0 -59
  30. data/exe/generate-test-session +0 -70
  31. data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +0 -288
  32. data/lib/gitlab_quality/test_tooling/report/test_health_issue_finder.rb +0 -79
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'table'
4
+
5
+ module GitlabQuality
6
+ module TestTooling
7
+ module CodeCoverage
8
+ module ClickHouse
9
+ class TestFileMappingsTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
10
+ TABLE_NAME = "test_file_mappings"
11
+
12
+ private
13
+
14
+ # @return [Boolean] True if the record is valid, false otherwise
15
+ def valid_record?(record)
16
+ valid_test_file?(record) && valid_source_file?(record)
17
+ end
18
+
19
+ # @return [Boolean] True if the test_file field is present
20
+ def valid_test_file?(record)
21
+ return true unless record[:test_file].blank?
22
+
23
+ logger.warn("#{LOG_PREFIX} Skipping record with nil/empty test_file: #{record}")
24
+ false
25
+ end
26
+
27
+ # @return [Boolean] True if the source_file field is present
28
+ def valid_source_file?(record)
29
+ return true unless record[:source_file].blank?
30
+
31
+ logger.warn("#{LOG_PREFIX} Skipping record with nil/empty source_file: #{record}")
32
+ false
33
+ end
34
+
35
+ # @return [Hash] Transformed mapping data including timestamp and CI metadata
36
+ def sanitized_data_record(record)
37
+ {
38
+ timestamp: time,
39
+ test_file: record[:test_file],
40
+ source_file: record[:source_file],
41
+ ci_project_path: ENV.fetch('CI_PROJECT_PATH', nil)
42
+ }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -4,22 +4,31 @@ module GitlabQuality
4
4
  module TestTooling
5
5
  module CodeCoverage
6
6
  class CoverageData
7
+ RESPONSIBLE = 'responsible'
8
+ DEPENDENT = 'dependent'
9
+
7
10
  # @param [Hash<String, Hash>] code_coverage_by_source_file Source file
8
11
  # mapped to test coverage data
9
12
  # @param [Hash<String, Array<String>>] source_file_to_tests Source files
10
13
  # mapped to all test files testing them
11
- # @param [Hash<String, Array<String>>] tests_to_categories Test files
14
+ # @param [Hash<String, Array<String>>] tests_to_feature_categories Test files
12
15
  # mapped to all feature categories they belong to
13
- # @param [Hash<String, Hash>] categories_to_teams Mapping of categories
16
+ # @param [Hash<String, Hash>] feature_categories_to_teams Mapping of feature categories
14
17
  # to teams (i.e., groups, stages, sections)
15
18
  # @param [Hash<String, String>] source_file_types Mapping of source files
16
19
  # to their types (frontend, backend, etc.)
17
- def initialize(code_coverage_by_source_file, source_file_to_tests, tests_to_categories, categories_to_teams, source_file_types = {})
20
+ # @param [Hash<String, String>] test_classifications Mapping of test files
21
+ # to their responsibility classification (responsible or dependent)
22
+ def initialize(
23
+ code_coverage_by_source_file, source_file_to_tests, tests_to_feature_categories,
24
+ feature_categories_to_teams, source_file_types = {}, test_classifications = {}
25
+ )
18
26
  @code_coverage_by_source_file = code_coverage_by_source_file
19
27
  @source_file_to_tests = source_file_to_tests
20
- @tests_to_categories = tests_to_categories
21
- @categories_to_teams = categories_to_teams
28
+ @tests_to_feature_categories = tests_to_feature_categories
29
+ @feature_categories_to_teams = feature_categories_to_teams
22
30
  @source_file_types = source_file_types
31
+ @test_classifications = test_classifications
23
32
  end
24
33
 
25
34
  # @return [Array<Hash<Symbol, String>>] Mapping of column name to row
@@ -32,7 +41,9 @@ module GitlabQuality
32
41
  # branch_coverage: 95.0
33
42
  # function_coverage: 100.0
34
43
  # source_file_type: "backend"
35
- # category: "team_planning"
44
+ # is_responsible: true
45
+ # is_dependent: false
46
+ # feature_category: "team_planning"
36
47
  # group: "project_management"
37
48
  # stage: "plan"
38
49
  # section: "dev"
@@ -40,55 +51,81 @@ module GitlabQuality
40
51
  # ...
41
52
  # ]
42
53
  def as_db_table
43
- all_files.flat_map do |file|
44
- coverage_data = @code_coverage_by_source_file[file]
45
- line_coverage = coverage_data&.dig(:percentage)
46
- branch_coverage = coverage_data&.dig(:branch_percentage)
47
- function_coverage = coverage_data&.dig(:function_percentage)
48
-
49
- categories = categories_for(file)
50
- base_data = {
51
- file: file,
52
- line_coverage: line_coverage,
53
- branch_coverage: branch_coverage,
54
- function_coverage: function_coverage,
55
- source_file_type: @source_file_types[file] || 'other'
56
- }
57
-
58
- if categories.empty?
59
- base_data.merge(no_owner_info)
60
- else
61
- categories.map do |category|
62
- base_data.merge(owner_info(category))
63
- end
54
+ all_files.flat_map { |file| records_for_file(file) }
55
+ end
56
+
57
+ private
58
+
59
+ def records_for_file(file)
60
+ base_data = base_data_for(file)
61
+ feature_categories_with_flags = feature_categories_with_responsibility_flags_for(file)
62
+
63
+ if feature_categories_with_flags.empty?
64
+ base_data.merge(no_owner_info).merge(is_responsible: nil, is_dependent: nil)
65
+ else
66
+ feature_categories_with_flags.map do |feature_category, flags|
67
+ base_data.merge(owner_info(feature_category)).merge(
68
+ is_responsible: flags[:is_responsible],
69
+ is_dependent: flags[:is_dependent]
70
+ )
64
71
  end
65
72
  end
66
73
  end
67
74
 
68
- private
75
+ def base_data_for(file)
76
+ coverage_data = @code_coverage_by_source_file[file]
77
+
78
+ {
79
+ file: file,
80
+ line_coverage: coverage_data&.dig(:percentage),
81
+ branch_coverage: coverage_data&.dig(:branch_percentage),
82
+ function_coverage: coverage_data&.dig(:function_percentage),
83
+ source_file_type: @source_file_types[file] || 'other'
84
+ }
85
+ end
69
86
 
70
87
  def no_owner_info
71
88
  {
72
- category: nil,
89
+ feature_category: nil,
73
90
  group: nil,
74
91
  stage: nil,
75
92
  section: nil
76
93
  }
77
94
  end
78
95
 
79
- def owner_info(category)
80
- owner_info = @categories_to_teams[category]
96
+ def owner_info(feature_category)
97
+ owner_info = @feature_categories_to_teams[feature_category]
81
98
 
82
99
  {
83
- category: category,
100
+ feature_category: feature_category,
84
101
  group: owner_info&.dig(:group),
85
102
  stage: owner_info&.dig(:stage),
86
103
  section: owner_info&.dig(:section)
87
104
  }
88
105
  end
89
106
 
90
- def categories_for(file)
91
- @source_file_to_tests[file]&.flat_map { |test_file| @tests_to_categories[test_file] || [] }&.uniq || []
107
+ # Returns a hash of feature_category => { is_responsible: bool, is_dependent: bool }
108
+ # for a given source file. A feature category can have both flags true if it has
109
+ # both unit tests (responsible) and integration/E2E tests (dependent).
110
+ def feature_categories_with_responsibility_flags_for(file)
111
+ test_files = @source_file_to_tests[file] || []
112
+ return {} if test_files.empty?
113
+
114
+ test_files.each_with_object({}) do |test_file, feature_category_to_flags|
115
+ feature_categories = @tests_to_feature_categories[test_file] || []
116
+ classification = @test_classifications[test_file]
117
+
118
+ feature_categories.each do |feature_category|
119
+ feature_category_to_flags[feature_category] ||= { is_responsible: false, is_dependent: false }
120
+
121
+ case classification
122
+ when RESPONSIBLE
123
+ feature_category_to_flags[feature_category][:is_responsible] = true
124
+ when DEPENDENT
125
+ feature_category_to_flags[feature_category][:is_dependent] = true
126
+ end
127
+ end
128
+ end
92
129
  end
93
130
 
94
131
  def all_files
@@ -85,7 +85,7 @@ module GitlabQuality
85
85
  end
86
86
 
87
87
  def register_source_file(filename)
88
- @current_file = filename.gsub(%r{^\./}, '')
88
+ @current_file = normalize_path(filename)
89
89
  @parsed_content[@current_file] = {
90
90
  line_coverage: {},
91
91
  branch_coverage: {},
@@ -94,6 +94,16 @@ module GitlabQuality
94
94
  }
95
95
  end
96
96
 
97
+ def normalize_path(filename)
98
+ # Remove leading ./ if present
99
+ path = filename.gsub(%r{^\./}, '')
100
+
101
+ # Handle GDK/CI paths like "../../../home/gdk/gitlab-development-kit/gitlab/app/..."
102
+ # Extract path starting from known root directories
103
+ match = path.match(%r{((?:ee/)?(?:app|lib|config|db|spec|scripts|tooling|workhorse|vendor)/.+)$})
104
+ match ? match[1] : path
105
+ end
106
+
97
107
  def register_line_data(line_no, count)
98
108
  return unless @current_file
99
109
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module CodeCoverage
6
+ class ResponsibilityClassifier
7
+ RESPONSIBLE = 'responsible'
8
+ DEPENDENT = 'dependent'
9
+
10
+ # @param test_to_sources [Hash<String, Array<String>>] Test files mapped to source files they cover
11
+ # @param responsible_patterns [Array<Regexp>] Patterns for unit tests
12
+ # @param dependent_patterns [Array<Regexp>] Patterns for integration/E2E tests
13
+ def initialize(test_to_sources, responsible_patterns:, dependent_patterns:)
14
+ @test_to_sources = test_to_sources
15
+ @responsible_patterns = responsible_patterns
16
+ @dependent_patterns = dependent_patterns
17
+ end
18
+
19
+ # Classifies each test file as responsible or dependent
20
+ # @return [Hash<String, String>] Test file path => classification
21
+ def classify_tests
22
+ @test_to_sources.keys.each_with_object({}) do |test_file, result|
23
+ result[test_file] = classify_test(test_file)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ # Classifies a test file as responsible (unit) or dependent (integration/E2E).
30
+ #
31
+ # Dependent patterns are checked first because it's the safer default:
32
+ # - is_responsible: true claims "this file has unit test coverage"
33
+ # - is_dependent: true claims "this file has integration test coverage"
34
+ #
35
+ # If uncertain (overlapping patterns or no match), we default to dependent
36
+ # to avoid incorrectly inflating unit test coverage metrics.
37
+ def classify_test(test_file)
38
+ return DEPENDENT if @dependent_patterns.any? { |p| test_file.match?(p) }
39
+ return RESPONSIBLE if @responsible_patterns.any? { |p| test_file.match?(p) }
40
+
41
+ # Default to dependent for unknown test types
42
+ DEPENDENT
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module GitlabQuality
6
+ module TestTooling
7
+ module CodeCoverage
8
+ class ResponsibilityPatternsConfig
9
+ ConfigError = Class.new(StandardError)
10
+
11
+ attr_reader :responsible_patterns, :dependent_patterns
12
+
13
+ # @param file_path [String] Path to YAML config file
14
+ # @raise [ConfigError] if file cannot be loaded or parsed
15
+ def initialize(file_path)
16
+ @file_path = file_path
17
+ @config = load_config
18
+ @responsible_patterns = parse_patterns('responsible')
19
+ @dependent_patterns = parse_patterns('dependent')
20
+ end
21
+
22
+ private
23
+
24
+ def load_config
25
+ YAML.load_file(@file_path)
26
+ rescue Errno::ENOENT
27
+ raise ConfigError, "Config file not found: #{@file_path}"
28
+ rescue Psych::SyntaxError => e
29
+ raise ConfigError, "Invalid YAML syntax in #{@file_path}: #{e.message}"
30
+ end
31
+
32
+ def parse_patterns(key)
33
+ patterns = @config[key]
34
+
35
+ raise ConfigError, "Missing or invalid '#{key}' key in #{@file_path}. Expected an array of patterns." unless patterns.is_a?(Array)
36
+
37
+ patterns.map do |pattern|
38
+ Regexp.new(pattern)
39
+ rescue RegexpError => e
40
+ raise ConfigError, "Invalid regex pattern '#{pattern}' in #{@file_path}: #{e.message}"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module CodeCoverage
6
+ class TestFileMappingData
7
+ # @param [Hash<String, Array<String>>] test_to_sources Test files
8
+ # mapped to all source files they cover
9
+ def initialize(test_to_sources)
10
+ @test_to_sources = test_to_sources
11
+ end
12
+
13
+ # @return [Array<Hash<Symbol, String>>] Mapping data formatted for database insertion
14
+ # @example Return value
15
+ # [
16
+ # { test_file: "spec/models/user_spec.rb", source_file: "app/models/user.rb" },
17
+ # { test_file: "spec/models/user_spec.rb", source_file: "lib/utils.rb" },
18
+ # ...
19
+ # ]
20
+ def as_db_table
21
+ @test_to_sources.flat_map do |test_file, source_files|
22
+ source_files.map do |source_file|
23
+ {
24
+ test_file: test_file,
25
+ source_file: source_file
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'erb'
4
-
5
3
  module GitlabQuality
6
4
  module TestTooling
7
5
  module Report
@@ -68,12 +66,12 @@ module GitlabQuality
68
66
  end
69
67
 
70
68
  def execution_graph_section(test)
71
- formatted_title = ERB::Util.url_encode(test.name)
69
+ formatted_path = CGI.escape(test.relative_file)
72
70
 
73
71
  <<~MKDOWN.strip
74
72
  ### Executions
75
73
 
76
- [Spec metrics on all environments](https://dashboards.quality.gitlab.net/d/cW0UMgv7k/single-spec-metrics?orgId=1&var-run_type=All&var-name=#{formatted_title})
74
+ [Spec metrics on all environments](https://dashboards.devex.gitlab.net/d/739c1bdd-a436-452b-bddc-fccb4d055768/single-test-overview?var-file_path=#{formatted_path})
77
75
  MKDOWN
78
76
  end
79
77
 
@@ -103,6 +103,10 @@ module GitlabQuality
103
103
  "#{ci_project_name}-#{test_subset}"
104
104
  end
105
105
 
106
+ def quarantine_disabled?
107
+ enabled?(ENV.fetch('GLCI_DISABLE_QUARANTINE', nil), default: false)
108
+ end
109
+
106
110
  private
107
111
 
108
112
  def enabled?(value, default: true)
@@ -45,7 +45,7 @@ module GitlabQuality
45
45
  commits.each_with_index.map do |(changed_line_number, spec), index|
46
46
  <<~MARKDOWN
47
47
  #{index + 1}. [`#{spec['name']}`](https://gitlab.com/#{context.project}/-/blob/#{context.ref}/#{spec['file_path']}#L#{changed_line_number.to_i + 1})
48
- | [Testcase](#{spec['testcase']}) | [Spec metrics](#{context.single_spec_metrics_link(spec['name'])})
48
+ | [Testcase](#{spec['testcase']}) | [Spec metrics](#{context.single_spec_metrics_link(spec['file_path'])})
49
49
  #{failure_issue_text(spec)}
50
50
  MARKDOWN
51
51
  end.join("\n")
@@ -348,11 +348,11 @@ module GitlabQuality
348
348
 
349
349
  # Returns the link to the Grafana dashboard for single spec metrics
350
350
  #
351
- # @param [String] example_name the full example name
351
+ # @param [String] file_path the full path of spec
352
352
  # @return [String]
353
- def single_spec_metrics_link(example_name)
354
- base_url = "https://dashboards.quality.gitlab.net/d/cW0UMgv7k/single-spec-metrics?orgId=1&var-run_type=All&var-name="
355
- base_url + CGI.escape(example_name)
353
+ def single_spec_metrics_link(file_path)
354
+ base_url = "https://dashboards.devex.gitlab.net/d/739c1bdd-a436-452b-bddc-fccb4d055768/single-test-overview?var-file_path="
355
+ base_url + CGI.escape(file_path)
356
356
  end
357
357
 
358
358
  # Returns any test description string within single or double quotes
@@ -52,6 +52,7 @@ module GitlabQuality
52
52
  :skip_record_proc,
53
53
  :test_retried_proc,
54
54
  :custom_metrics_proc,
55
+ :spec_file_path_prefix,
55
56
  :logger
56
57
 
57
58
  # rubocop:disable Style/TrivialAccessors -- allows documenting that setting config enables the export as well as document input class type
@@ -108,6 +109,13 @@ module GitlabQuality
108
109
  @extra_rspec_metadata_keys ||= []
109
110
  end
110
111
 
112
+ # Extra path prefix for constructing full file path within mono-repository setups
113
+ #
114
+ # @return [String]
115
+ def spec_file_path_prefix
116
+ @spec_file_path_prefix ||= ""
117
+ end
118
+
111
119
  # A lambda that determines whether to skip recording a test result
112
120
  #
113
121
  # This is useful when you would want to skip initial failure when retrying specs is set up in a separate process
@@ -46,12 +46,16 @@ module GitlabQuality
46
46
  status: example.execution_result.status,
47
47
  run_time: (example.execution_result.run_time * 1000).round,
48
48
  location: example_location,
49
- exception_class: exception_class,
49
+ # TODO: remove exception_class once migration to exception_classes is fully complete on clickhouse side
50
+ exception_class: example.execution_result.exception&.class&.to_s,
51
+ exception_classes: exception_classes.map { |e| e.class.to_s }.uniq,
50
52
  failure_exception: failure_exception,
51
53
  quarantined: quarantined?,
54
+ quarantine_issue_url: quarantine_issue_url || "",
52
55
  feature_category: example.metadata[:feature_category] || "",
53
56
  test_retried: config.test_retried_proc.call(example),
54
- run_type: run_type
57
+ run_type: run_type,
58
+ spec_file_path_prefix: config.spec_file_path_prefix
55
59
  }
56
60
  end
57
61
 
@@ -95,6 +99,25 @@ module GitlabQuality
95
99
  example.execution_result.status == :pending
96
100
  end
97
101
 
102
+ # Extract quarantine issue URL from metadata
103
+ #
104
+ # @return [String, nil]
105
+ def quarantine_issue_url
106
+ return nil unless example.metadata.key?(:quarantine)
107
+
108
+ metadata = example.metadata[:quarantine]
109
+ case metadata
110
+ when String
111
+ # Direct URL: quarantine: 'https://gitlab.com/.../issues/123'
112
+ metadata if metadata.start_with?('http')
113
+ when Hash
114
+ # Hash format: quarantine: { issue: 'https://...', reason: '...' }
115
+ issue = metadata[:issue] || metadata['issue']
116
+ # Handle array of URLs (take the first one)
117
+ issue.is_a?(Array) ? issue.first : issue
118
+ end
119
+ end
120
+
98
121
  # Base ci job name
99
122
  #
100
123
  # @return [String]
@@ -127,22 +150,25 @@ module GitlabQuality
127
150
  @file_path ||= example_location.gsub(/:\d+$/, "")
128
151
  end
129
152
 
130
- # Failure exception class
153
+ # Failure exception classes
131
154
  #
132
- # @return [String]
133
- def exception_class
134
- example.execution_result.exception&.class&.to_s
155
+ # @return [Array<Exception>]
156
+ def exception_classes
157
+ exception = example.execution_result.exception
158
+ return [] unless exception
159
+ return [exception] unless exception.respond_to?(:all_exceptions)
160
+
161
+ exception.all_exceptions.flatten
135
162
  end
136
163
 
137
164
  # Truncated exception stacktrace
138
165
  #
139
166
  # @return [String]
140
167
  def failure_exception
141
- example.execution_result.exception.then do |exception|
142
- next unless exception
168
+ exception = example.execution_result.exception
169
+ return unless exception
143
170
 
144
- exception.to_s.tr("\n", " ").slice(0, 1000)
145
- end
171
+ exception.to_s.tr("\n", " ").slice(0, 1000)
146
172
  end
147
173
 
148
174
  # Test run type | suite name
@@ -65,6 +65,7 @@ module GitlabQuality
65
65
  test_retried Bool,
66
66
  feature_category LowCardinality(String) DEFAULT 'unknown',
67
67
  run_type LowCardinality(String) DEFAULT 'unknown',
68
+ spec_file_path_prefix LowCardinality(String) DEFAULT '',
68
69
  ci_project_id UInt32,
69
70
  ci_job_name LowCardinality(String),
70
71
  ci_job_id UInt64,
@@ -75,6 +76,7 @@ module GitlabQuality
75
76
  ci_target_branch LowCardinality(String),
76
77
  ci_server_url LowCardinality(String) DEFAULT 'https://gitlab.com',
77
78
  exception_class String DEFAULT '',
79
+ exception_classes Array(String) DEFAULT [],
78
80
  failure_exception String DEFAULT ''
79
81
  )
80
82
  ENGINE = MergeTree()
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core/formatters/base_formatter"
4
+
5
+ module GitlabQuality
6
+ module TestTooling
7
+ module TestQuarantine
8
+ class QuarantineFormatter < ::RSpec::Core::Formatters::BaseFormatter
9
+ include QuarantineHelper
10
+
11
+ ::RSpec::Core::Formatters.register(
12
+ self,
13
+ :example_group_started,
14
+ :example_started
15
+ )
16
+
17
+ # Starts example group
18
+ # @param [RSpec::Core::Notifications::GroupNotification] example_group_notification
19
+ # @return [void]
20
+ def example_group_started(example_group_notification)
21
+ group = example_group_notification.group
22
+
23
+ skip_or_run_quarantined_tests_or_contexts(group)
24
+ end
25
+
26
+ # Starts example
27
+ # @param [RSpec::Core::Notifications::ExampleNotification] example_notification
28
+ # @return [void]
29
+ def example_started(example_notification)
30
+ example = example_notification.example
31
+
32
+ # if skip propagated from example_group, do not reset skip metadata
33
+ skip_or_run_quarantined_tests_or_contexts(example) unless example.metadata[:skip]
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+
5
+ module GitlabQuality
6
+ module TestTooling
7
+ module TestQuarantine
8
+ module QuarantineHelper
9
+ include ::RSpec::Core::Pending
10
+
11
+ extend self
12
+
13
+ # Skip tests in quarantine unless we explicitly focus on them or quarantine disabled
14
+ def skip_or_run_quarantined_tests_or_contexts(example)
15
+ return if Runtime::Env.quarantine_disabled?
16
+
17
+ if filters.key?(:quarantine)
18
+ included_filters = filters_other_than_quarantine
19
+
20
+ # If :quarantine is focused, skip the test/context unless its metadata
21
+ # includes quarantine and any other filters
22
+ # E.g., Suppose a test is tagged :smoke and :quarantine, and another is tagged
23
+ # :ldap and :quarantine. If we wanted to run just quarantined smoke tests
24
+ # using `--tag quarantine --tag smoke`, without this check we'd end up
25
+ # running that ldap test as well because of the :quarantine metadata.
26
+ # We could use an exclusion filter, but this way the test report will list
27
+ # the quarantined tests when they're not run so that we're aware of them
28
+ if should_skip_when_focused?(example.metadata, included_filters)
29
+ example.metadata[:skip] = "Only running tests tagged with :quarantine and any of #{included_filters.keys}"
30
+ end
31
+ elsif example.metadata.key?(:quarantine)
32
+ quarantine_tag = example.metadata[:quarantine]
33
+
34
+ example.metadata[:skip] = quarantine_message(quarantine_tag)
35
+ end
36
+ end
37
+
38
+ def filters_other_than_quarantine
39
+ filters.except(:quarantine)
40
+ end
41
+
42
+ def quarantine_message(quarantine_tag)
43
+ quarantine_message = %w[In quarantine]
44
+ quarantine_message << case quarantine_tag
45
+ when String
46
+ ": #{quarantine_tag}"
47
+ when Hash
48
+ quarantine_tag.key?(:issue) ? ": #{quarantine_tag[:issue]}" : ''
49
+ else
50
+ ''
51
+ end
52
+
53
+ quarantine_message.join(' ').strip
54
+ end
55
+
56
+ # Checks if a test or context should be skipped.
57
+ #
58
+ # Returns true if
59
+ # - the metadata does not includes the :quarantine tag
60
+ # or if
61
+ # - the metadata includes the :quarantine tag
62
+ # - and the filter includes other tags that aren't in the metadata
63
+ def should_skip_when_focused?(metadata, included_filters)
64
+ return true unless metadata.key?(:quarantine)
65
+ return false if included_filters.empty?
66
+
67
+ !metadata.keys.intersect?(included_filters.keys)
68
+ end
69
+
70
+ def filters
71
+ @filters ||= ::RSpec.configuration.inclusion_filter.rules
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end