gitlab_quality-test_tooling 2.26.0 → 3.0.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 +1 -1
- data/exe/test-coverage +77 -35
- data/lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb +25 -10
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb +44 -13
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb +2 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb +23 -5
- data/lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb +93 -15
- data/lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier.rb +94 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/test_report.rb +43 -0
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f037a73b3fd4a445324a37084915ea2d020475cca6f4e3b1d23045f290351082
|
|
4
|
+
data.tar.gz: e32bc045856df09004e725b9b84317c74ffb66b59ed640b078e292f46a3ff565
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 400c1e51bf57a3f10cdb36c083444153326da8710edd828839881867aed4d9282da4b21fa16357f49bb498ac48199dda888e422e1ddf4f5453769cb5bdc92211
|
|
7
|
+
data.tar.gz: cc071d0ca3c54a0d3408f6fc91165348c403ab5109c473d7bc518701a17f90d992f02139614dfb99fd53cac7e0ffef688241c7c232cf373344269ffe3e8b4436
|
data/Gemfile.lock
CHANGED
data/exe/test-coverage
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "optparse"
|
|
5
|
+
require "uri"
|
|
5
6
|
|
|
6
7
|
require_relative "../lib/gitlab_quality/test_tooling"
|
|
7
8
|
|
|
@@ -11,17 +12,22 @@ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/c
|
|
|
11
12
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/coverage_data'
|
|
12
13
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/lcov_file'
|
|
13
14
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/artifacts'
|
|
14
|
-
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/
|
|
15
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_report'
|
|
15
16
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
|
|
17
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier'
|
|
16
18
|
|
|
17
19
|
params = {}
|
|
18
|
-
required_params = [:
|
|
20
|
+
required_params = [:test_reports, :coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username]
|
|
19
21
|
|
|
20
22
|
options = OptionParser.new do |opts|
|
|
21
23
|
opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
|
|
22
24
|
|
|
23
|
-
opts.
|
|
24
|
-
|
|
25
|
+
opts.separator ""
|
|
26
|
+
opts.separator "Options:"
|
|
27
|
+
|
|
28
|
+
opts.on('--test-reports GLOB',
|
|
29
|
+
'Glob pattern for test JSON reports (RSpec or Jest) (e.g., "reports/**/*.json")') do |pattern|
|
|
30
|
+
params[:test_reports] = pattern
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
opts.on('--coverage-report PATH', 'Path to the LCOV coverage report (e.g., "coverage/lcov/gitlab.lcov")') do |path|
|
|
@@ -32,6 +38,23 @@ options = OptionParser.new do |opts|
|
|
|
32
38
|
params[:test_map] = path
|
|
33
39
|
end
|
|
34
40
|
|
|
41
|
+
opts.on('--clickhouse-url URL', 'ClickHouse server URL') do |url|
|
|
42
|
+
params[:clickhouse_url] = url
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
opts.on('--clickhouse-database DATABASE', 'ClickHouse database name') do |database|
|
|
46
|
+
params[:clickhouse_database] = database
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
opts.on('--clickhouse-username USERNAME', 'ClickHouse username') do |username|
|
|
50
|
+
params[:clickhouse_username] = username
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
opts.separator ""
|
|
54
|
+
opts.separator "Environment variables:"
|
|
55
|
+
opts.separator " GLCI_CLICKHOUSE_METRICS_PASSWORD ClickHouse password (required, not passed via CLI for security)"
|
|
56
|
+
opts.separator ""
|
|
57
|
+
|
|
35
58
|
opts.on('-h', '--help', 'Show the usage') do
|
|
36
59
|
puts opts
|
|
37
60
|
puts "\nExamples:"
|
|
@@ -49,63 +72,82 @@ options = OptionParser.new do |opts|
|
|
|
49
72
|
end
|
|
50
73
|
|
|
51
74
|
if params.any? && (required_params - params.keys).none?
|
|
75
|
+
clickhouse_password = ENV.fetch('GLCI_CLICKHOUSE_METRICS_PASSWORD', nil)
|
|
76
|
+
if clickhouse_password.to_s.strip.empty?
|
|
77
|
+
puts "Error: GLCI_CLICKHOUSE_METRICS_PASSWORD environment variable must be set and not empty"
|
|
78
|
+
exit 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
[:clickhouse_url, :clickhouse_database, :clickhouse_username].each do |param|
|
|
82
|
+
if params[param].to_s.strip.empty?
|
|
83
|
+
puts "Error: --#{param.to_s.tr('_', '-')} cannot be empty"
|
|
84
|
+
exit 1
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
uri = URI.parse(params[:clickhouse_url])
|
|
90
|
+
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
91
|
+
puts "Error: --clickhouse-url must be a valid HTTP or HTTPS URL"
|
|
92
|
+
exit 1
|
|
93
|
+
end
|
|
94
|
+
rescue URI::InvalidURIError
|
|
95
|
+
puts "Error: --clickhouse-url is not a valid URL format"
|
|
96
|
+
exit 1
|
|
97
|
+
end
|
|
98
|
+
|
|
52
99
|
artifacts = GitlabQuality::TestTooling::CodeCoverage::Artifacts.new(
|
|
53
|
-
rspec_reports: params[:rspec_reports],
|
|
54
100
|
coverage_report: params[:coverage_report],
|
|
55
|
-
test_map: params[:test_map]
|
|
101
|
+
test_map: params[:test_map],
|
|
102
|
+
test_reports: params[:test_reports]
|
|
56
103
|
)
|
|
57
104
|
|
|
58
105
|
coverage_report = artifacts.coverage_report
|
|
59
|
-
rspec_reports = artifacts.rspec_reports
|
|
60
106
|
test_map = artifacts.test_map
|
|
61
107
|
|
|
62
108
|
code_coverage_by_source_file = GitlabQuality::TestTooling::CodeCoverage::LcovFile.new(coverage_report).parsed_content
|
|
63
109
|
|
|
64
110
|
source_file_to_tests = GitlabQuality::TestTooling::CodeCoverage::TestMap.new(test_map).source_to_tests
|
|
65
111
|
|
|
66
|
-
|
|
67
|
-
|
|
112
|
+
# Process test reports
|
|
113
|
+
tests_to_categories = artifacts.test_reports.reduce({}) do |combined_hash, test_report_file|
|
|
114
|
+
file_categories = GitlabQuality::TestTooling::CodeCoverage::TestReport.new(test_report_file).tests_to_categories
|
|
68
115
|
combined_hash.merge(file_categories) { |_, old_val, new_val| (old_val + new_val).uniq }
|
|
69
116
|
end
|
|
70
117
|
|
|
71
118
|
category_owners = GitlabQuality::TestTooling::CodeCoverage::CategoryOwners.new
|
|
72
119
|
|
|
120
|
+
# Classify source files by type (frontend, backend, etc.)
|
|
121
|
+
source_file_classifier = GitlabQuality::TestTooling::CodeCoverage::SourceFileClassifier.new
|
|
122
|
+
source_file_types = source_file_classifier.classify(code_coverage_by_source_file.keys)
|
|
123
|
+
|
|
73
124
|
coverage_data = GitlabQuality::TestTooling::CodeCoverage::CoverageData.new(
|
|
74
125
|
code_coverage_by_source_file,
|
|
75
126
|
source_file_to_tests,
|
|
76
127
|
tests_to_categories,
|
|
77
|
-
category_owners.categories_to_teams
|
|
128
|
+
category_owners.categories_to_teams,
|
|
129
|
+
source_file_types
|
|
78
130
|
)
|
|
79
131
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
url: ENV.fetch('CLICKHOUSE_URL', nil),
|
|
87
|
-
database: ENV.fetch('CLICKHOUSE_DATABASE', nil),
|
|
88
|
-
username: ENV.fetch('CLICKHOUSE_USERNAME', nil),
|
|
89
|
-
password: ENV.fetch('CLICKHOUSE_PASSWORD', nil)
|
|
90
|
-
}
|
|
132
|
+
clickhouse_data = {
|
|
133
|
+
url: params[:clickhouse_url],
|
|
134
|
+
database: params[:clickhouse_database],
|
|
135
|
+
username: params[:clickhouse_username],
|
|
136
|
+
password: clickhouse_password
|
|
137
|
+
}
|
|
91
138
|
|
|
92
|
-
|
|
93
|
-
|
|
139
|
+
category_owners_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CategoryOwnersTable.new(**clickhouse_data)
|
|
140
|
+
coverage_metrics_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CoverageMetricsTable.new(**clickhouse_data)
|
|
94
141
|
|
|
95
|
-
|
|
96
|
-
|
|
142
|
+
category_owners_table.create if ENV['CLICKHOUSE_CREATE_CATEGORY_OWNERS_TABLE'] == 'true'
|
|
143
|
+
coverage_metrics_table.create if ENV['CLICKHOUSE_CREATE_COVERAGE_METRICS_TABLE'] == 'true'
|
|
97
144
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
coverage_metrics_table.push(coverage_data.as_db_table)
|
|
104
|
-
else
|
|
105
|
-
puts "ClickHouse configuration not found.\n" \
|
|
106
|
-
'Set CLICKHOUSE_URL, CLICKHOUSE_DATABASE, CLICKHOUSE_USERNAME, ' \
|
|
107
|
-
'CLICKHOUSE_PASSWORD environment variables to enable ClickHouse export.'
|
|
145
|
+
if ENV['CLICKHOUSE_PUSH_CATEGORY_DATA'] == 'true'
|
|
146
|
+
category_owners_table.truncate
|
|
147
|
+
category_owners_table.push(category_owners.as_db_table)
|
|
108
148
|
end
|
|
149
|
+
|
|
150
|
+
coverage_metrics_table.push(coverage_data.as_db_table)
|
|
109
151
|
else
|
|
110
152
|
puts "Missing argument(s). Required arguments are: #{required_params}\nPassed arguments are: #{params}\n"
|
|
111
153
|
puts options
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'stringio'
|
|
5
5
|
require 'zlib'
|
|
6
|
+
require 'active_support/core_ext/object/blank'
|
|
6
7
|
|
|
7
8
|
module GitlabQuality
|
|
8
9
|
module TestTooling
|
|
@@ -10,38 +11,52 @@ module GitlabQuality
|
|
|
10
11
|
class Artifacts
|
|
11
12
|
# Loads coverage artifacts from the filesystem
|
|
12
13
|
#
|
|
13
|
-
# @param
|
|
14
|
+
# @param test_reports [String] Glob pattern for test JSON report files (RSpec or Jest) (e.g., "reports/**/*.json")
|
|
14
15
|
# @param coverage_report [String] Path to the LCOV coverage report file (e.g., "coverage/lcov/gitlab.lcov")
|
|
15
16
|
# @param test_map [String] Path to the test map file, gzipped or plain JSON (e.g., "crystalball/packed-mapping.json.gz")
|
|
16
|
-
def initialize(
|
|
17
|
-
|
|
17
|
+
def initialize(coverage_report:, test_map:, test_reports:)
|
|
18
|
+
raise ArgumentError, "test_reports cannot be blank" if test_reports.blank?
|
|
19
|
+
|
|
20
|
+
@test_reports_glob = test_reports
|
|
18
21
|
@coverage_report_path = coverage_report
|
|
19
22
|
@test_map_path = test_map
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
# Loads and parses test JSON report files (RSpec or Jest)
|
|
26
|
+
#
|
|
27
|
+
# @return [Array<Hash>] Array of parsed JSON test reports
|
|
28
|
+
# @raise [RuntimeError] If no test reports are found or if JSON parsing fails
|
|
29
|
+
def test_reports
|
|
30
|
+
@test_report_files ||= test_reports_paths.map do |report_path|
|
|
24
31
|
JSON.parse(File.read(report_path))
|
|
25
32
|
rescue JSON::ParserError => e
|
|
26
|
-
raise "Invalid JSON in
|
|
33
|
+
raise "Invalid JSON in test report file #{report_path}: #{e.message}"
|
|
27
34
|
end
|
|
28
35
|
end
|
|
29
36
|
|
|
37
|
+
# Loads the LCOV coverage report file
|
|
38
|
+
#
|
|
39
|
+
# @return [String] Raw content of the LCOV coverage report
|
|
40
|
+
# @raise [RuntimeError] If the coverage report file is not found
|
|
30
41
|
def coverage_report
|
|
31
42
|
@coverage_report ||= read_coverage_reports
|
|
32
43
|
end
|
|
33
44
|
|
|
45
|
+
# Loads and parses the test map file (supports gzipped or plain JSON)
|
|
46
|
+
#
|
|
47
|
+
# @return [Hash] Parsed test map data
|
|
48
|
+
# @raise [RuntimeError] If the test map file is not found or cannot be parsed
|
|
34
49
|
def test_map
|
|
35
50
|
@test_map ||= fetch_test_map
|
|
36
51
|
end
|
|
37
52
|
|
|
38
53
|
private
|
|
39
54
|
|
|
40
|
-
def
|
|
41
|
-
@
|
|
42
|
-
paths = Dir.glob(@
|
|
55
|
+
def test_reports_paths
|
|
56
|
+
@test_reports_paths ||= begin
|
|
57
|
+
paths = Dir.glob(@test_reports_glob)
|
|
43
58
|
|
|
44
|
-
raise "No
|
|
59
|
+
raise "No test reports found matching pattern: #{@test_reports_glob}" if paths.empty?
|
|
45
60
|
|
|
46
61
|
paths
|
|
47
62
|
end
|
|
@@ -19,7 +19,10 @@ module GitlabQuality
|
|
|
19
19
|
CREATE TABLE IF NOT EXISTS #{table_name} (
|
|
20
20
|
timestamp DateTime64(6, 'UTC'),
|
|
21
21
|
file String,
|
|
22
|
-
|
|
22
|
+
line_coverage Float64,
|
|
23
|
+
branch_coverage Nullable(Float64),
|
|
24
|
+
function_coverage Nullable(Float64),
|
|
25
|
+
source_file_type String,
|
|
23
26
|
category Nullable(String),
|
|
24
27
|
ci_project_id Nullable(UInt32),
|
|
25
28
|
ci_project_path Nullable(String),
|
|
@@ -42,22 +45,47 @@ module GitlabQuality
|
|
|
42
45
|
|
|
43
46
|
# @return [Boolean] True if the record is valid, false otherwise
|
|
44
47
|
def valid_record?(record)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
valid_file?(record) &&
|
|
49
|
+
valid_line_coverage?(record) &&
|
|
50
|
+
valid_branch_coverage?(record) &&
|
|
51
|
+
valid_function_coverage?(record)
|
|
52
|
+
end
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
# @return [Boolean] True if the file field is present
|
|
55
|
+
def valid_file?(record)
|
|
56
|
+
return true unless record[:file].nil?
|
|
57
|
+
|
|
58
|
+
logger.warn("#{LOG_PREFIX} Skipping record with nil file: #{record}")
|
|
59
|
+
false
|
|
60
|
+
end
|
|
54
61
|
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
# @return [Boolean] True if line_coverage is present and not NaN
|
|
63
|
+
def valid_line_coverage?(record)
|
|
64
|
+
if record[:line_coverage].nil?
|
|
65
|
+
logger.warn("#{LOG_PREFIX} Skipping record with nil line_coverage: #{record}")
|
|
57
66
|
return false
|
|
58
67
|
end
|
|
59
68
|
|
|
60
|
-
true
|
|
69
|
+
return true unless record[:line_coverage].nan?
|
|
70
|
+
|
|
71
|
+
logger.warn("#{LOG_PREFIX} Skipping record with NaN line_coverage: #{record}")
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Boolean] True if branch_coverage is not NaN (or is nil)
|
|
76
|
+
def valid_branch_coverage?(record)
|
|
77
|
+
return true unless record[:branch_coverage]&.nan?
|
|
78
|
+
|
|
79
|
+
logger.warn("#{LOG_PREFIX} Skipping record with NaN branch_coverage: #{record}")
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean] True if function_coverage is not NaN (or is nil)
|
|
84
|
+
def valid_function_coverage?(record)
|
|
85
|
+
return true unless record[:function_coverage]&.nan?
|
|
86
|
+
|
|
87
|
+
logger.warn("#{LOG_PREFIX} Skipping record with NaN function_coverage: #{record}")
|
|
88
|
+
false
|
|
61
89
|
end
|
|
62
90
|
|
|
63
91
|
# @return [Hash] Transformed coverage data including timestamp and CI metadata
|
|
@@ -65,7 +93,10 @@ module GitlabQuality
|
|
|
65
93
|
{
|
|
66
94
|
timestamp: time,
|
|
67
95
|
file: record[:file],
|
|
68
|
-
|
|
96
|
+
line_coverage: record[:line_coverage],
|
|
97
|
+
branch_coverage: record[:branch_coverage],
|
|
98
|
+
function_coverage: record[:function_coverage],
|
|
99
|
+
source_file_type: record[:source_file_type],
|
|
69
100
|
category: record[:category],
|
|
70
101
|
**ci_metadata
|
|
71
102
|
}
|
|
@@ -23,6 +23,8 @@ module GitlabQuality
|
|
|
23
23
|
logger.debug("#{LOG_PREFIX} Starting data export to ClickHouse")
|
|
24
24
|
sanitized_data = sanitize(data)
|
|
25
25
|
|
|
26
|
+
return logger.warn("#{LOG_PREFIX} No valid data found after sanitization, skipping ClickHouse export!") if sanitized_data.empty?
|
|
27
|
+
|
|
26
28
|
client.insert_json_data(table_name, sanitized_data)
|
|
27
29
|
logger.info("#{LOG_PREFIX} Successfully pushed #{sanitized_data.size} records to #{full_table_name}!")
|
|
28
30
|
rescue StandardError => e
|
|
@@ -12,11 +12,14 @@ module GitlabQuality
|
|
|
12
12
|
# mapped to all feature categories they belong to
|
|
13
13
|
# @param [Hash<String, Hash>] categories_to_teams Mapping of categories
|
|
14
14
|
# to teams (i.e., groups, stages, sections)
|
|
15
|
-
|
|
15
|
+
# @param [Hash<String, String>] source_file_types Mapping of source files
|
|
16
|
+
# 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 = {})
|
|
16
18
|
@code_coverage_by_source_file = code_coverage_by_source_file
|
|
17
19
|
@source_file_to_tests = source_file_to_tests
|
|
18
20
|
@tests_to_categories = tests_to_categories
|
|
19
21
|
@categories_to_teams = categories_to_teams
|
|
22
|
+
@source_file_types = source_file_types
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
# @return [Array<Hash<Symbol, String>>] Mapping of column name to row
|
|
@@ -25,7 +28,10 @@ module GitlabQuality
|
|
|
25
28
|
# [
|
|
26
29
|
# {
|
|
27
30
|
# file: "app/channels/application_cable/channel.rb"
|
|
28
|
-
#
|
|
31
|
+
# line_coverage: 100.0
|
|
32
|
+
# branch_coverage: 95.0
|
|
33
|
+
# function_coverage: 100.0
|
|
34
|
+
# source_file_type: "backend"
|
|
29
35
|
# category: "team_planning"
|
|
30
36
|
# group: "project_management"
|
|
31
37
|
# stage: "plan"
|
|
@@ -35,13 +41,25 @@ module GitlabQuality
|
|
|
35
41
|
# ]
|
|
36
42
|
def as_db_table
|
|
37
43
|
all_files.flat_map do |file|
|
|
38
|
-
|
|
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
|
+
|
|
39
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
|
+
|
|
40
58
|
if categories.empty?
|
|
41
|
-
|
|
59
|
+
base_data.merge(no_owner_info)
|
|
42
60
|
else
|
|
43
61
|
categories.map do |category|
|
|
44
|
-
|
|
62
|
+
base_data.merge(owner_info(category))
|
|
45
63
|
end
|
|
46
64
|
end
|
|
47
65
|
end
|
|
@@ -17,7 +17,13 @@ module GitlabQuality
|
|
|
17
17
|
# branch_coverage: {},
|
|
18
18
|
# total_lines: 2,
|
|
19
19
|
# covered_lines: 1,
|
|
20
|
-
# percentage: 50.0
|
|
20
|
+
# percentage: 50.0,
|
|
21
|
+
# total_branches: 4,
|
|
22
|
+
# covered_branches: 3,
|
|
23
|
+
# branch_percentage: 75.0,
|
|
24
|
+
# total_functions: 2,
|
|
25
|
+
# covered_functions: 1,
|
|
26
|
+
# function_percentage: 50.0
|
|
21
27
|
# },
|
|
22
28
|
# ...
|
|
23
29
|
# }
|
|
@@ -27,18 +33,7 @@ module GitlabQuality
|
|
|
27
33
|
@parsed_content = {}
|
|
28
34
|
@current_file = nil
|
|
29
35
|
|
|
30
|
-
@lcov_file_content.each_line
|
|
31
|
-
case line
|
|
32
|
-
when /^SF:(.+)$/
|
|
33
|
-
register_source_file(::Regexp.last_match(1))
|
|
34
|
-
when /^DA:(\d+),(\d+)$/
|
|
35
|
-
register_line_data(::Regexp.last_match(1), ::Regexp.last_match(2))
|
|
36
|
-
when /^BRDA:(\d+),(\d+),(\d+),(-|\d+)$/
|
|
37
|
-
register_branch_data(::Regexp.last_match(1), ::Regexp.last_match(4))
|
|
38
|
-
when /^end_of_record$/
|
|
39
|
-
@current_file = nil
|
|
40
|
-
end
|
|
41
|
-
end
|
|
36
|
+
@lcov_file_content.each_line { |line| parse_line(line) }
|
|
42
37
|
|
|
43
38
|
include_coverage
|
|
44
39
|
@parsed_content
|
|
@@ -46,10 +41,32 @@ module GitlabQuality
|
|
|
46
41
|
|
|
47
42
|
private
|
|
48
43
|
|
|
44
|
+
def parse_line(line)
|
|
45
|
+
case line
|
|
46
|
+
when /^SF:(.+)$/
|
|
47
|
+
register_source_file(::Regexp.last_match(1))
|
|
48
|
+
when /^DA:(\d+),(\d+)$/
|
|
49
|
+
register_line_data(::Regexp.last_match(1), ::Regexp.last_match(2))
|
|
50
|
+
when /^BRDA:(\d+),(\d+),(\d+),(-|\d+)$/
|
|
51
|
+
register_branch_data(::Regexp.last_match(1), ::Regexp.last_match(4))
|
|
52
|
+
when /^FNF:(\d+)$/
|
|
53
|
+
register_functions_found(::Regexp.last_match(1))
|
|
54
|
+
when /^FNH:(\d+)$/
|
|
55
|
+
register_functions_hit(::Regexp.last_match(1))
|
|
56
|
+
when /^BRF:(\d+)$/
|
|
57
|
+
register_branches_found(::Regexp.last_match(1))
|
|
58
|
+
when /^BRH:(\d+)$/
|
|
59
|
+
register_branches_hit(::Regexp.last_match(1))
|
|
60
|
+
when /^end_of_record$/
|
|
61
|
+
@current_file = nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
49
65
|
def include_coverage
|
|
50
66
|
@parsed_content.each_key do |file|
|
|
51
|
-
# TODO: Support branch coverage too
|
|
52
67
|
@parsed_content[file].merge!(line_coverage_for(file))
|
|
68
|
+
@parsed_content[file].merge!(branch_coverage_for(file))
|
|
69
|
+
@parsed_content[file].merge!(function_coverage_for(file))
|
|
53
70
|
end
|
|
54
71
|
end
|
|
55
72
|
|
|
@@ -69,7 +86,12 @@ module GitlabQuality
|
|
|
69
86
|
|
|
70
87
|
def register_source_file(filename)
|
|
71
88
|
@current_file = filename.gsub(%r{^\./}, '')
|
|
72
|
-
@parsed_content[@current_file] = {
|
|
89
|
+
@parsed_content[@current_file] = {
|
|
90
|
+
line_coverage: {},
|
|
91
|
+
branch_coverage: {},
|
|
92
|
+
branches: { found: 0, hit: 0 },
|
|
93
|
+
functions: { found: 0, hit: 0 }
|
|
94
|
+
}
|
|
73
95
|
end
|
|
74
96
|
|
|
75
97
|
def register_line_data(line_no, count)
|
|
@@ -85,6 +107,62 @@ module GitlabQuality
|
|
|
85
107
|
@parsed_content[@current_file][:branch_coverage][line_no.to_i] ||= []
|
|
86
108
|
@parsed_content[@current_file][:branch_coverage][line_no.to_i] << taken_count
|
|
87
109
|
end
|
|
110
|
+
|
|
111
|
+
def register_functions_found(count)
|
|
112
|
+
return unless @current_file
|
|
113
|
+
|
|
114
|
+
@parsed_content[@current_file][:functions][:found] = count.to_i
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def register_functions_hit(count)
|
|
118
|
+
return unless @current_file
|
|
119
|
+
|
|
120
|
+
@parsed_content[@current_file][:functions][:hit] = count.to_i
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def register_branches_found(count)
|
|
124
|
+
return unless @current_file
|
|
125
|
+
|
|
126
|
+
@parsed_content[@current_file][:branches][:found] = count.to_i
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def register_branches_hit(count)
|
|
130
|
+
return unless @current_file
|
|
131
|
+
|
|
132
|
+
@parsed_content[@current_file][:branches][:hit] = count.to_i
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def branch_coverage_for(file)
|
|
136
|
+
data = @parsed_content[file]
|
|
137
|
+
return {} unless data && data[:branches]
|
|
138
|
+
|
|
139
|
+
total = data[:branches][:found]
|
|
140
|
+
covered = data[:branches][:hit]
|
|
141
|
+
|
|
142
|
+
return {} if total.nil? || total.zero?
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
total_branches: total,
|
|
146
|
+
covered_branches: covered,
|
|
147
|
+
branch_percentage: (covered.to_f / total * 100).round(2)
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def function_coverage_for(file)
|
|
152
|
+
data = @parsed_content[file]
|
|
153
|
+
return {} unless data && data[:functions]
|
|
154
|
+
|
|
155
|
+
total = data[:functions][:found]
|
|
156
|
+
covered = data[:functions][:hit]
|
|
157
|
+
|
|
158
|
+
return {} if total.nil? || total.zero?
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
total_functions: total,
|
|
162
|
+
covered_functions: covered,
|
|
163
|
+
function_percentage: (covered.to_f / total * 100).round(2)
|
|
164
|
+
}
|
|
165
|
+
end
|
|
88
166
|
end
|
|
89
167
|
end
|
|
90
168
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabQuality
|
|
4
|
+
module TestTooling
|
|
5
|
+
module CodeCoverage
|
|
6
|
+
class SourceFileClassifier
|
|
7
|
+
PATTERNS = {
|
|
8
|
+
'frontend' => [
|
|
9
|
+
%r{^app/assets/javascripts/.*\.(js|jsx|ts|tsx|vue)$},
|
|
10
|
+
%r{^app/assets/stylesheets/.*\.(css|scss)$},
|
|
11
|
+
%r{^ee/app/assets/javascripts/.*\.(js|jsx|ts|tsx|vue)$},
|
|
12
|
+
%r{^ee/app/assets/stylesheets/.*\.(css|scss)$},
|
|
13
|
+
%r{^jh/app/assets/javascripts/.*\.(js|jsx|ts|tsx|vue)$},
|
|
14
|
+
%r{^spec/frontend/},
|
|
15
|
+
%r{^ee/spec/frontend/},
|
|
16
|
+
%r{^spec/frontend_integration/},
|
|
17
|
+
%r{^app/assets/javascripts/.*\.graphql$},
|
|
18
|
+
/\.stories\.js$/
|
|
19
|
+
],
|
|
20
|
+
'backend' => [
|
|
21
|
+
%r{^app/(models|controllers|services|workers|helpers|mailers|policies|presenters|uploaders|validators|enums|events|experiments|facades|channels)/.*\.rb$},
|
|
22
|
+
%r{^app/serializers/.*\.rb$},
|
|
23
|
+
%r{^app/graphql/.*\.rb$},
|
|
24
|
+
%r{^app/components/.*\.rb$},
|
|
25
|
+
%r{^app/views/.*\.(haml|erb)$},
|
|
26
|
+
%r{^lib/.*\.rb$},
|
|
27
|
+
%r{^lib/api/.*\.rb$},
|
|
28
|
+
%r{^ee/app/.*\.rb$},
|
|
29
|
+
%r{^ee/lib/.*\.rb$},
|
|
30
|
+
%r{^jh/app/.*\.rb$},
|
|
31
|
+
%r{^jh/lib/.*\.rb$},
|
|
32
|
+
%r{^spec/.*_spec\.rb$},
|
|
33
|
+
%r{^ee/spec/.*_spec\.rb$},
|
|
34
|
+
%r{^lib/tasks/.*\.rake$}
|
|
35
|
+
],
|
|
36
|
+
'database' => [
|
|
37
|
+
%r{^db/migrate/.*\.rb$},
|
|
38
|
+
%r{^db/post_migrate/.*\.rb$},
|
|
39
|
+
%r{^ee/db/geo/migrate/.*\.rb$},
|
|
40
|
+
%r{^db/structure\.sql$},
|
|
41
|
+
%r{^db/seeds\.rb$},
|
|
42
|
+
%r{^db/fixtures/}
|
|
43
|
+
],
|
|
44
|
+
'infrastructure' => [
|
|
45
|
+
/^\.gitlab-ci\.yml$/,
|
|
46
|
+
%r{^\.gitlab/ci/.*\.(yml|yaml)$},
|
|
47
|
+
/Dockerfile/,
|
|
48
|
+
/\.dockerfile$/,
|
|
49
|
+
%r{^scripts/pipeline/}
|
|
50
|
+
],
|
|
51
|
+
'qa' => [
|
|
52
|
+
%r{^qa/.*\.rb$}
|
|
53
|
+
],
|
|
54
|
+
'workhorse' => [
|
|
55
|
+
%r{^workhorse/.*\.go$}
|
|
56
|
+
],
|
|
57
|
+
'tooling' => [
|
|
58
|
+
%r{^tooling/.*\.(rb|js)$},
|
|
59
|
+
%r{^rubocop/.*\.rb$},
|
|
60
|
+
%r{^danger/.*\.rb$},
|
|
61
|
+
/^\.rubocop\.yml$/
|
|
62
|
+
],
|
|
63
|
+
'configuration' => [
|
|
64
|
+
%r{^config/.*\.(yml|yaml|rb)$}
|
|
65
|
+
]
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
# Classifies a collection of file paths into their respective types
|
|
69
|
+
#
|
|
70
|
+
# @param file_paths [Array<String>] List of file paths to classify
|
|
71
|
+
# @return [Hash<String, String>] Hash mapping file path to file type
|
|
72
|
+
def classify(file_paths)
|
|
73
|
+
file_paths.each_with_object({}) do |file_path, result|
|
|
74
|
+
result[file_path] = determine_type(file_path)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Determines the type of a single file based on pattern matching
|
|
81
|
+
#
|
|
82
|
+
# @param file_path [String] The file path to classify
|
|
83
|
+
# @return [String] The file type category
|
|
84
|
+
def determine_type(file_path)
|
|
85
|
+
PATTERNS.each do |type, patterns|
|
|
86
|
+
return type if patterns.any? { |pattern| file_path.match?(pattern) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
'other'
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module GitlabQuality
|
|
6
|
+
module TestTooling
|
|
7
|
+
module CodeCoverage
|
|
8
|
+
class TestReport
|
|
9
|
+
# @param [Hash<String, Object>] test_report The content of a test
|
|
10
|
+
# report (RSpec or Jest)
|
|
11
|
+
def initialize(test_report)
|
|
12
|
+
@test_report = test_report
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [Array<Hash<String, String>>] Content of the "examples"
|
|
16
|
+
# section of the test report
|
|
17
|
+
def examples
|
|
18
|
+
@examples ||= @test_report['examples']
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Hash<String, Array<String>>] Test files mapped to all feature
|
|
22
|
+
# categories they belong to
|
|
23
|
+
# @example Return value
|
|
24
|
+
# {
|
|
25
|
+
# "spec/path/to/file_spec.rb" => [
|
|
26
|
+
# "feature_category1", "feature_category2"
|
|
27
|
+
# ],
|
|
28
|
+
# ...
|
|
29
|
+
# }
|
|
30
|
+
def tests_to_categories
|
|
31
|
+
@tests_to_categories ||= examples.to_a.filter_map do |example|
|
|
32
|
+
next unless example.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
file_path = example['file_path']
|
|
35
|
+
next unless file_path.is_a?(String)
|
|
36
|
+
|
|
37
|
+
[file_path.gsub('./', ''), Array(example['feature_category']).compact]
|
|
38
|
+
end.to_h
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitlab_quality-test_tooling
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 3.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GitLab Quality
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-11-
|
|
11
|
+
date: 2025-11-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: climate_control
|
|
@@ -498,7 +498,9 @@ files:
|
|
|
498
498
|
- lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb
|
|
499
499
|
- lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb
|
|
500
500
|
- lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb
|
|
501
|
+
- lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier.rb
|
|
501
502
|
- lib/gitlab_quality/test_tooling/code_coverage/test_map.rb
|
|
503
|
+
- lib/gitlab_quality/test_tooling/code_coverage/test_report.rb
|
|
502
504
|
- lib/gitlab_quality/test_tooling/code_coverage/utils.rb
|
|
503
505
|
- lib/gitlab_quality/test_tooling/concerns/find_set_dri.rb
|
|
504
506
|
- lib/gitlab_quality/test_tooling/failed_jobs_table.rb
|