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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 125bc8b9a38435053e2e82849dd2a41c241d05d47784626234f01702c5619671
4
- data.tar.gz: 56255580e30a0fd8896a456e4f079bea4a8de9c2a363d73263dc641f613df786
3
+ metadata.gz: f037a73b3fd4a445324a37084915ea2d020475cca6f4e3b1d23045f290351082
4
+ data.tar.gz: e32bc045856df09004e725b9b84317c74ffb66b59ed640b078e292f46a3ff565
5
5
  SHA512:
6
- metadata.gz: 041e97993894d0edc5d61c0e5e4c5bba688f81fdd92d6bec8d06e19787b3219daad4400d2aedc6c9da2a35e44f526bc8cc4960dce8016ad269f769be482c6ee5
7
- data.tar.gz: eb2eef2912be06edf872af18ef1ff8ae1722462b694aa07f57f7a42e138ae8a0327aaa35914a778472e9104b9d08d407075026d2a48ac7ca9b0a5a9ad64944ad
6
+ metadata.gz: 400c1e51bf57a3f10cdb36c083444153326da8710edd828839881867aed4d9282da4b21fa16357f49bb498ac48199dda888e422e1ddf4f5453769cb5bdc92211
7
+ data.tar.gz: cc071d0ca3c54a0d3408f6fc91165348c403ab5109c473d7bc518701a17f90d992f02139614dfb99fd53cac7e0ffef688241c7c232cf373344269ffe3e8b4436
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (2.26.0)
4
+ gitlab_quality-test_tooling (3.0.0)
5
5
  activesupport (>= 7.0, < 7.3)
6
6
  amatch (~> 0.4.1)
7
7
  fog-google (~> 1.24, >= 1.24.1)
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/rspec_report'
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 = [:rspec_reports, :coverage_report, :test_map]
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.on('--rspec-reports GLOB', 'Glob pattern for RSpec JSON reports (e.g., "rspec/rspec-*.json")') do |pattern|
24
- params[:rspec_reports] = pattern
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
- tests_to_categories = rspec_reports.reduce({}) do |combined_hash, rspec_report_file|
67
- file_categories = GitlabQuality::TestTooling::CodeCoverage::RspecReport.new(rspec_report_file).tests_to_categories
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
- if ENV.fetch('CLICKHOUSE_URL', nil) &&
81
- ENV.fetch('CLICKHOUSE_DATABASE', nil) &&
82
- ENV.fetch('CLICKHOUSE_USERNAME', nil) &&
83
- ENV.fetch('CLICKHOUSE_PASSWORD', nil)
84
-
85
- clickhouse_data = {
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
- category_owners_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CategoryOwnersTable.new(**clickhouse_data)
93
- coverage_metrics_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CoverageMetricsTable.new(**clickhouse_data)
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
- category_owners_table.create if ENV['CLICKHOUSE_CREATE_CATEGORY_OWNERS_TABLE'] == 'true'
96
- coverage_metrics_table.create if ENV['CLICKHOUSE_CREATE_COVERAGE_METRICS_TABLE'] == 'true'
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
- if ENV['CLICKHOUSE_PUSH_CATEGORY_DATA'] == 'true'
99
- category_owners_table.truncate
100
- category_owners_table.push(category_owners.as_db_table)
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 rspec_reports [String] Glob pattern for RSpec JSON report files (e.g., "rspec/rspec-*.json")
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(rspec_reports:, coverage_report:, test_map:)
17
- @rspec_reports_glob = rspec_reports
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
- def rspec_reports
23
- @rspec_report_files ||= rspec_reports_paths.map do |report_path|
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 RSpec report file #{report_path}: #{e.message}"
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 rspec_reports_paths
41
- @rspec_reports_paths ||= begin
42
- paths = Dir.glob(@rspec_reports_glob)
55
+ def test_reports_paths
56
+ @test_reports_paths ||= begin
57
+ paths = Dir.glob(@test_reports_glob)
43
58
 
44
- raise "No RSpec reports found matching pattern: #{@rspec_reports_glob}" if paths.empty?
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
- coverage Float64,
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
- if record[:file].nil?
46
- logger.warn("#{LOG_PREFIX} Skipping record with nil file: #{record}")
47
- return false
48
- end
48
+ valid_file?(record) &&
49
+ valid_line_coverage?(record) &&
50
+ valid_branch_coverage?(record) &&
51
+ valid_function_coverage?(record)
52
+ end
49
53
 
50
- if record[:coverage].nil?
51
- logger.warn("#{LOG_PREFIX} Skipping record with nil coverage: #{record}")
52
- return false
53
- end
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
- if record[:coverage].nan?
56
- logger.warn("#{LOG_PREFIX} Skipping record with NaN coverage: #{record}")
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
- coverage: record[:coverage],
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
- def initialize(code_coverage_by_source_file, source_file_to_tests, tests_to_categories, categories_to_teams)
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
- # coverage: 100.0
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
- coverage = @code_coverage_by_source_file[file]&.dig(:percentage)
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
- { file: file, coverage: coverage }.merge(no_owner_info)
59
+ base_data.merge(no_owner_info)
42
60
  else
43
61
  categories.map do |category|
44
- { file: file, coverage: coverage }.merge(owner_info(category))
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 do |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] = { line_coverage: {}, branch_coverage: {} }
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "2.26.0"
5
+ VERSION = "3.0.0"
6
6
  end
7
7
  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: 2.26.0
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-05 00:00:00.000000000 Z
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