gitlab_quality-test_tooling 3.2.1 → 3.4.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: 7463b8e510a9decd2156d3167e28d6109c99928b03cc5738be19eba7ecccfa39
4
- data.tar.gz: 2094146a3e6ebcbbcf17ede753e5b1c822b8c33b397cc02550b5c17dac1d19a6
3
+ metadata.gz: 870c9853d71d7274b721d9f7bf0b6e23466da21ca5dec47a66aae9d3ebc62ea0
4
+ data.tar.gz: 242a18c953ae4259bce53f875c110be58e79d1b2477b07f8871d78a64aa7c5c6
5
5
  SHA512:
6
- metadata.gz: d1fe5d32ed4521023381d238ad1a5bd17d7b36ea32b9bd888697e0126452a36fab14d2884d3d665f55620cd76d7f9da5048b5bb18b9f61746b0b49e49ed291f5
7
- data.tar.gz: df998fa7999ae675f4de19227bef692594427c02c30116f9312fbcc07d73312bcccb634efabafe20cea3fc7fa947bb85feab19829b13b1071fd5e5626ed0c5a9
6
+ metadata.gz: 4469d2f5013331527e49b45356563d7f8f4de34c2a49eb9ea4860cfc87fdb96979d4d3d05152403264f5564a70febdb91cc46e4e93373d2cd6b16924641a400e
7
+ data.tar.gz: 585059565492ed297f43f60bc13ec7c747e6a5cacb1330e9a8f828735b6dd1e151c72cba938b251c9cff8bca4926d191d0b8b336da2deb069827b841d666989d
data/Gemfile.lock CHANGED
@@ -1,13 +1,12 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (3.2.1)
4
+ gitlab_quality-test_tooling (3.4.0)
5
5
  activesupport (>= 7.0)
6
6
  amatch (~> 0.4.1)
7
7
  fog-google (~> 1.24, >= 1.24.1)
8
8
  gitlab (>= 4.19, < 7.0)
9
9
  http (~> 5.0)
10
- influxdb-client (~> 3.1)
11
10
  nokogiri (~> 1.10)
12
11
  parallel (>= 1, < 2)
13
12
  rainbow (>= 3, < 4)
@@ -200,7 +199,6 @@ GEM
200
199
  httpclient (2.8.3)
201
200
  i18n (1.14.6)
202
201
  concurrent-ruby (~> 1.0)
203
- influxdb-client (3.1.0)
204
202
  jaro_winkler (1.6.0)
205
203
  json (2.7.2)
206
204
  jwt (2.9.3)
data/exe/test-coverage CHANGED
@@ -10,11 +10,13 @@ require_relative "../lib/gitlab_quality/test_tooling"
10
10
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/category_owners'
11
11
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table'
12
12
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table'
13
+ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table'
13
14
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/coverage_data'
14
15
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/lcov_file'
15
16
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/artifacts'
16
17
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_report'
17
18
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
19
+ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data'
18
20
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier'
19
21
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier'
20
22
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config'
@@ -167,15 +169,17 @@ if params.any? && (required_params - params.keys).none?
167
169
  category_owners_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CategoryOwnersTable.new(**clickhouse_data)
168
170
  coverage_metrics_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CoverageMetricsTable.new(**clickhouse_data)
169
171
 
170
- category_owners_table.create if ENV['CLICKHOUSE_CREATE_CATEGORY_OWNERS_TABLE'] == 'true'
171
- coverage_metrics_table.create if ENV['CLICKHOUSE_CREATE_COVERAGE_METRICS_TABLE'] == 'true'
172
-
173
172
  if ENV['CLICKHOUSE_PUSH_CATEGORY_DATA'] == 'true'
174
173
  category_owners_table.truncate
175
174
  category_owners_table.push(category_owners.as_db_table)
176
175
  end
177
176
 
178
177
  coverage_metrics_table.push(coverage_data.as_db_table)
178
+
179
+ # Export test-to-file mappings
180
+ test_file_mapping_data = GitlabQuality::TestTooling::CodeCoverage::TestFileMappingData.new(test_to_sources)
181
+ test_file_mappings_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestFileMappingsTable.new(**clickhouse_data)
182
+ test_file_mappings_table.push(test_file_mapping_data.as_db_table)
179
183
  else
180
184
  puts "Missing argument(s). Required arguments are: #{required_params}\nPassed arguments are: #{params}\n"
181
185
  puts options
@@ -4,6 +4,7 @@ Exports test coverage data to ClickHouse, enriched with:
4
4
 
5
5
  - **Feature category ownership** - group, stage, and section for each covered file
6
6
  - **Responsibility classification** - whether coverage comes from unit or integration tests
7
+ - **Test-to-file mappings** - which source files each test covers
7
8
 
8
9
  ## How It Works
9
10
 
@@ -134,6 +135,15 @@ dependent:
134
135
  - "^spec/features/"
135
136
  ```
136
137
 
138
+ ## Test-to-File Mappings
139
+
140
+ When a test map is provided, the module also exports test-to-source-file relationships
141
+ to a separate `test_file_mappings` table. This enables:
142
+
143
+ - **Coverage context for tests** - see which source files a specific test covers
144
+ - **Impact analysis** - understand which files would lose coverage if a test is quarantined
145
+ - **Flaky test triage** - correlate flaky tests with the source files they cover
146
+
137
147
  ## CLI
138
148
 
139
149
  Example usage:
@@ -11,29 +11,6 @@ module GitlabQuality
11
11
 
12
12
  MissingMappingError = Class.new(StandardError)
13
13
 
14
- # Creates the ClickHouse table, if it doesn't exist already
15
- # @return [nil]
16
- def create
17
- logger.debug("#{LOG_PREFIX} Creating category_owners table if it doesn't exist ...")
18
-
19
- client.query(<<~SQL)
20
- CREATE TABLE IF NOT EXISTS #{table_name} (
21
- timestamp DateTime64(6, 'UTC') DEFAULT now64(),
22
- category String,
23
- group String,
24
- stage String,
25
- section String,
26
- INDEX idx_group group TYPE set(360) GRANULARITY 1,
27
- INDEX idx_stage stage TYPE set(360) GRANULARITY 1,
28
- INDEX idx_section section TYPE set(360) GRANULARITY 1
29
- ) ENGINE = MergeTree()
30
- ORDER BY (category, timestamp)
31
- SETTINGS index_granularity = 8192;
32
- SQL
33
-
34
- logger.info("#{LOG_PREFIX} Category owners table created/verified successfully")
35
- end
36
-
37
14
  def truncate
38
15
  logger.debug("#{LOG_PREFIX} Truncating table #{full_table_name} ...")
39
16
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'time'
4
3
  require_relative 'table'
5
4
 
6
5
  module GitlabQuality
@@ -10,39 +9,6 @@ module GitlabQuality
10
9
  class CoverageMetricsTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
11
10
  TABLE_NAME = "coverage_metrics"
12
11
 
13
- # Creates the ClickHouse table, if it doesn't exist already
14
- # @return [nil]
15
- def create
16
- logger.debug("#{LOG_PREFIX} Creating coverage_metrics table if it doesn't exist ...")
17
-
18
- client.query(<<~SQL)
19
- CREATE TABLE IF NOT EXISTS #{table_name} (
20
- timestamp DateTime64(6, 'UTC'),
21
- file String,
22
- line_coverage Float64,
23
- branch_coverage Nullable(Float64),
24
- function_coverage Nullable(Float64),
25
- source_file_type String,
26
- is_responsible Nullable(Bool),
27
- is_dependent Nullable(Bool),
28
- category Nullable(String),
29
- ci_project_id Nullable(UInt32),
30
- ci_project_path Nullable(String),
31
- ci_job_name Nullable(String),
32
- ci_job_id Nullable(UInt64),
33
- ci_pipeline_id Nullable(UInt64),
34
- ci_merge_request_iid Nullable(UInt32),
35
- ci_branch Nullable(String),
36
- ci_target_branch Nullable(String)
37
- ) ENGINE = MergeTree()
38
- PARTITION BY toYYYYMM(timestamp)
39
- ORDER BY (ci_project_path, timestamp, file, ci_pipeline_id)
40
- SETTINGS index_granularity = 8192, allow_nullable_key = 1;
41
- SQL
42
-
43
- logger.info("#{LOG_PREFIX} Coverage metrics table created/verified successfully")
44
- end
45
-
46
12
  private
47
13
 
48
14
  # @return [Boolean] True if the record is valid, false otherwise
@@ -106,14 +72,6 @@ module GitlabQuality
106
72
  }
107
73
  end
108
74
 
109
- # @return [Time] Common timestamp for all coverage records
110
- def time
111
- @time ||= begin
112
- ci_created_at = ENV.fetch('CI_PIPELINE_CREATED_AT', nil)
113
- ci_created_at ? Time.strptime(ci_created_at, '%Y-%m-%dT%H:%M:%S%z') : Time.now.utc
114
- end
115
- end
116
-
117
75
  # @return [Hash] CI-related metadata
118
76
  def ci_metadata
119
77
  {
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module GitlabQuality
4
6
  module TestTooling
5
7
  module CodeCoverage
@@ -58,6 +60,21 @@ module GitlabQuality
58
60
  raise NotImplementedError, "#{self.class}##{__method__} method must be implemented in a subclass"
59
61
  end
60
62
 
63
+ # @return [Time] Common timestamp for all records, memoized
64
+ def time
65
+ @time ||= parse_ci_timestamp
66
+ end
67
+
68
+ def parse_ci_timestamp
69
+ ci_created_at = ENV.fetch('CI_PIPELINE_CREATED_AT', nil)
70
+ return Time.now.utc unless ci_created_at
71
+
72
+ Time.strptime(ci_created_at, '%Y-%m-%dT%H:%M:%S%z')
73
+ rescue ArgumentError
74
+ logger.warn("#{LOG_PREFIX} Invalid CI_PIPELINE_CREATED_AT format: #{ci_created_at}, using current time")
75
+ Time.now.utc
76
+ end
77
+
61
78
  # @return [GitlabQuality::TestTooling::ClickHouse::Client]
62
79
  def client
63
80
  @client ||= GitlabQuality::TestTooling::ClickHouse::Client.new(
@@ -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
@@ -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,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
@@ -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)
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "3.2.1"
5
+ VERSION = "3.4.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: 3.2.1
4
+ version: 3.4.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-12-12 00:00:00.000000000 Z
11
+ date: 2026-01-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: climate_control
@@ -302,20 +302,6 @@ dependencies:
302
302
  - - "~>"
303
303
  - !ruby/object:Gem::Version
304
304
  version: '5.0'
305
- - !ruby/object:Gem::Dependency
306
- name: influxdb-client
307
- requirement: !ruby/object:Gem::Requirement
308
- requirements:
309
- - - "~>"
310
- - !ruby/object:Gem::Version
311
- version: '3.1'
312
- type: :runtime
313
- prerelease: false
314
- version_requirements: !ruby/object:Gem::Requirement
315
- requirements:
316
- - - "~>"
317
- - !ruby/object:Gem::Version
318
- version: '3.1'
319
305
  - !ruby/object:Gem::Dependency
320
306
  name: nokogiri
321
307
  requirement: !ruby/object:Gem::Requirement
@@ -490,12 +476,14 @@ files:
490
476
  - lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb
491
477
  - lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb
492
478
  - lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb
479
+ - lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table.rb
493
480
  - lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb
494
481
  - lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb
495
482
  - lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier.rb
496
483
  - lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb
497
484
  - lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb
498
485
  - lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier.rb
486
+ - lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data.rb
499
487
  - lib/gitlab_quality/test_tooling/code_coverage/test_map.rb
500
488
  - lib/gitlab_quality/test_tooling/code_coverage/test_report.rb
501
489
  - lib/gitlab_quality/test_tooling/code_coverage/utils.rb
@@ -592,6 +580,8 @@ files:
592
580
  - lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb
593
581
  - lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb
594
582
  - lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb
583
+ - lib/gitlab_quality/test_tooling/test_quarantine/quarantine_formatter.rb
584
+ - lib/gitlab_quality/test_tooling/test_quarantine/quarantine_helper.rb
595
585
  - lib/gitlab_quality/test_tooling/test_result/base_test_result.rb
596
586
  - lib/gitlab_quality/test_tooling/test_result/j_unit_test_result.rb
597
587
  - lib/gitlab_quality/test_tooling/test_result/json_test_result.rb