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.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -4
- data/README.md +0 -14
- data/exe/test-coverage +51 -8
- data/lib/gitlab_quality/test_tooling/code_coverage/README.md +162 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb +5 -2
- data/lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb +26 -26
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb +13 -27
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb +3 -41
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb +17 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table.rb +48 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb +71 -34
- data/lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb +11 -1
- data/lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier.rb +47 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb +46 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data.rb +33 -0
- data/lib/gitlab_quality/test_tooling/report/results_in_test_cases.rb +2 -4
- data/lib/gitlab_quality/test_tooling/runtime/env.rb +4 -0
- data/lib/gitlab_quality/test_tooling/test_meta/processor/meta_processor.rb +1 -1
- data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +4 -4
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +8 -0
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +36 -10
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +2 -0
- data/lib/gitlab_quality/test_tooling/test_quarantine/quarantine_formatter.rb +38 -0
- data/lib/gitlab_quality/test_tooling/test_quarantine/quarantine_helper.rb +76 -0
- data/lib/gitlab_quality/test_tooling/test_result/base_test_result.rb +15 -1
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- metadata +9 -28
- data/exe/existing-test-health-issue +0 -59
- data/exe/generate-test-session +0 -70
- data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +0 -288
- 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>>]
|
|
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>]
|
|
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
|
-
|
|
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
|
-
@
|
|
21
|
-
@
|
|
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
|
-
#
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
80
|
-
owner_info = @
|
|
96
|
+
def owner_info(feature_category)
|
|
97
|
+
owner_info = @feature_categories_to_teams[feature_category]
|
|
81
98
|
|
|
82
99
|
{
|
|
83
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
|
@@ -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['
|
|
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]
|
|
351
|
+
# @param [String] file_path the full path of spec
|
|
352
352
|
# @return [String]
|
|
353
|
-
def single_spec_metrics_link(
|
|
354
|
-
base_url = "https://dashboards.
|
|
355
|
-
base_url + CGI.escape(
|
|
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
|
-
|
|
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
|
|
153
|
+
# Failure exception classes
|
|
131
154
|
#
|
|
132
|
-
# @return [
|
|
133
|
-
def
|
|
134
|
-
example.execution_result.exception
|
|
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
|
|
142
|
-
|
|
168
|
+
exception = example.execution_result.exception
|
|
169
|
+
return unless exception
|
|
143
170
|
|
|
144
|
-
|
|
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
|