From c8e8bb000c2a7add814f8123a64786a4e829c215 Mon Sep 17 00:00:00 2001 From: Harsha Muralidhar <hmuralidhar@gitlab.com> Date: Mon, 17 Jun 2024 16:28:16 +0000 Subject: [PATCH] Introduce coverband gem for mapping source code paths --- .gitlab/ci/qa-common/main.gitlab-ci.yml | 11 ++ .gitlab/ci/rules.gitlab-ci.yml | 10 +- .gitlab/ci/test-on-gdk/main.gitlab-ci.yml | 8 + Gemfile | 3 + Gemfile.checksum | 1 + Gemfile.lock | 3 + config/initializers/coverband.rb | 19 +++ lib/api/api.rb | 1 + lib/api/internal/coverage.rb | 36 ++++ qa/qa/runtime/env.rb | 4 + qa/qa/specs/spec_helper.rb | 1 + .../support/formatters/coverband_formatter.rb | 97 +++++++++++ qa/qa/tools/ci/export_code_paths_mapping.rb | 69 ++++++++ .../formatters/coverband_formatter_spec.rb | 155 ++++++++++++++++++ .../ci/export_code_paths_mapping_spec.rb | 55 +++++++ qa/tasks/ci.rake | 7 + scripts/generate-e2e-pipeline | 1 + spec/requests/api/internal/coverage_spec.rb | 55 +++++++ 18 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 config/initializers/coverband.rb create mode 100644 lib/api/internal/coverage.rb create mode 100644 qa/qa/support/formatters/coverband_formatter.rb create mode 100644 qa/qa/tools/ci/export_code_paths_mapping.rb create mode 100644 qa/spec/support/formatters/coverband_formatter_spec.rb create mode 100644 qa/spec/tools/ci/export_code_paths_mapping_spec.rb create mode 100644 spec/requests/api/internal/coverage_spec.rb diff --git a/.gitlab/ci/qa-common/main.gitlab-ci.yml b/.gitlab/ci/qa-common/main.gitlab-ci.yml index 8e0cec312a523..d4260ae38e2ea 100644 --- a/.gitlab/ci/qa-common/main.gitlab-ci.yml +++ b/.gitlab/ci/qa-common/main.gitlab-ci.yml @@ -127,6 +127,17 @@ stages: script: - bundle exec rake "ci:export_test_metrics[$QA_METRICS_REPORT_FILE_PATTERN]" +.export-code-paths-mapping: + extends: + - .qa-install + - .ruby-image + stage: report + when: always + variables: + QA_CODE_PATHS_MAPPING_FILE_PATTERN: $CI_PROJECT_DIR/gitlab-qa-run-*/**/test-code-paths-mapping-*.json + script: + - bundle exec rake "ci:export_code_paths_mapping[$QA_CODE_PATHS_MAPPING_FILE_PATTERN]" + .generate-test-session: extends: - .qa-install diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index bab08b5765565..a6b3ea7935192 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -1738,7 +1738,15 @@ # Certain components trigger a rebuild of the e2e GDK image so we want to test it too - <<: *if-merge-request changes: *gdk-component-patterns - - !reference [".qa:rules:e2e-schedule-blocking", rules] + - <<: *if-dot-com-gitlab-org-schedule + variables: + CREATE_TEST_FAILURE_ISSUES: "true" + PROCESS_TEST_RESULTS: "true" + KNAPSACK_GENERATE_REPORT: "true" + QA_SAVE_TEST_METRICS: "true" + QA_EXPORT_TEST_METRICS: "false" + COVERBAND_ENABLED: "false" #Will open a separate MR to set to true + .qa:rules:e2e:test-on-cng: rules: diff --git a/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml b/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml index de54fc007c857..2d3007f3fe8bc 100644 --- a/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml +++ b/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml @@ -206,6 +206,14 @@ export-test-metrics: variables: QA_METRICS_REPORT_FILE_PATTERN: $CI_PROJECT_DIR/qa/tmp/test-metrics-*.json +export-code-paths-mapping: + extends: + - .export-code-paths-mapping + variables: + QA_CODE_PATHS_MAPPING_FILE_PATTERN: $CI_PROJECT_DIR/qa/tmp/test-code-paths-mapping-*.json + rules: + - if: '$COVERBAND_ENABLED == "true"' + .gitlab-qa-report: variables: QA_RSPEC_JSON_FILE_PATTERN: $CI_PROJECT_DIR/qa/tmp/rspec-*.json diff --git a/Gemfile b/Gemfile index b628778538b58..d8f93f9280b18 100644 --- a/Gemfile +++ b/Gemfile @@ -64,6 +64,9 @@ gem 'marginalia', '~> 1.11.1' # rubocop:todo Gemfile/MissingFeatureCategory # Authorization gem 'declarative_policy', '~> 1.1.0' # rubocop:todo Gemfile/MissingFeatureCategory +# For source code paths mapping +gem 'coverband', '6.1.2', require: false, feature_category: :shared + # Authentication libraries gem 'devise', '~> 4.9.3', feature_category: :system_access gem 'devise-pbkdf2-encryptable', '~> 0.0.0', path: 'vendor/gems/devise-pbkdf2-encryptable' # rubocop:todo Gemfile/MissingFeatureCategory diff --git a/Gemfile.checksum b/Gemfile.checksum index df217a86fc6a0..ddaa782552117 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -91,6 +91,7 @@ {"name":"cork","version":"0.3.0","platform":"ruby","checksum":"a0a0ac50e262f8514d1abe0a14e95e71c98b24e3378690e5d044daf0013ad4bc"}, {"name":"cose","version":"1.3.0","platform":"ruby","checksum":"63247c66a5bc76e53926756574fe3724cc0a88707e358c90532ae2a320e98601"}, {"name":"countries","version":"4.0.1","platform":"ruby","checksum":"d32e8a3c0b22949f1a41ea6d9005f5168ffce226f8fe077d1d6be785fffa81c5"}, +{"name":"coverband","version":"6.1.2","platform":"ruby","checksum":"979f356b0f1ff63a78b6ffbbbd0dccb3674c83d74d7e6fbdc3ee98f1ef35ba32"}, {"name":"crack","version":"0.4.3","platform":"ruby","checksum":"5318ba8cd9cf7e0b5feb38948048503ba4b1fdc1b6ff30a39f0a00feb6036b29"}, {"name":"crass","version":"1.0.6","platform":"ruby","checksum":"dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d"}, {"name":"creole","version":"0.5.0","platform":"ruby","checksum":"951701e2d80760f156b1cb2a93471ca97c076289becc067a33b745133ed32c03"}, diff --git a/Gemfile.lock b/Gemfile.lock index e16cc0192235d..4ddf2b5443d1e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -420,6 +420,8 @@ GEM countries (4.0.1) i18n_data (~> 0.13.0) sixarm_ruby_unaccent (~> 1.1) + coverband (6.1.2) + redis (>= 3.0) crack (0.4.3) safe_yaml (~> 1.0.0) crass (1.0.6) @@ -1948,6 +1950,7 @@ DEPENDENCIES concurrent-ruby (~> 1.1) connection_pool (~> 2.4) countries (~> 4.0.0) + coverband (= 6.1.2) creole (~> 0.5.0) crystalball (~> 0.7.0) cssbundling-rails (= 1.4.0) diff --git a/config/initializers/coverband.rb b/config/initializers/coverband.rb new file mode 100644 index 0000000000000..aed72a3ff61e0 --- /dev/null +++ b/config/initializers/coverband.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Configuration used by coverband gem when "COVERBAND_ENABLED" is set to "true" +return unless Gitlab::Utils.to_boolean(ENV['COVERBAND_ENABLED'], default: false) + +require 'coverband' + +Coverband.configure do |config| + config.store = Coverband::Adapters::RedisStore.new(Gitlab::Redis::SharedState.redis) + config.background_reporting_sleep_seconds = 1 + config.reporting_wiggle = nil # Since this is not run in production disable wiggle and report every second. + config.ignore += %w[spec/.* lib/tasks/.* + config/application.rb config/boot.rb config/initializers/.* db/post_migrate/.* + config/puma.rb bin/.* config/environments/.* db/migrate/.*] + + config.verbose = true + config.csp_policy = true + config.logger = Gitlab::AppLogger +end diff --git a/lib/api/api.rb b/lib/api/api.rb index 2a8f03892a828..1eb4c60af12c7 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -385,6 +385,7 @@ def initialize(location_url) end mount ::API::Internal::Base + mount ::API::Internal::Coverage if Gitlab::Utils.to_boolean(ENV['COVERBAND_ENABLED'], default: false) mount ::API::Internal::Lfs mount ::API::Internal::Pages mount ::API::Internal::Kubernetes diff --git a/lib/api/internal/coverage.rb b/lib/api/internal/coverage.rb new file mode 100644 index 0000000000000..cba50280e5d6e --- /dev/null +++ b/lib/api/internal/coverage.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# This api is for internal use only for the purpose of source code paths mapping to E2E specs. + +module API + module Internal + class Coverage < ::API::Base + feature_category :code_testing + urgency :low + + before do + authenticated_as_admin! + end + + namespace 'internal' do + namespace 'coverage' do + desc 'Source code paths coverage mapping' do + success code: 200, message: 'Success' + failure [ + { code: 401, message: 'Unauthorized' } + ] + end + + get do + coverage = ::Coverband.configuration.store.coverage + coverage&.keys + end + + delete do + ::Coverband.configuration.store.clear! + end + end + end + end + end +end diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb index 2328bd833d8e9..c01d0e795f58d 100644 --- a/qa/qa/runtime/env.rb +++ b/qa/qa/runtime/env.rb @@ -90,6 +90,10 @@ def ci_project_path ENV['CI_PROJECT_PATH'] end + def coverband_enabled? + enabled?(ENV['COVERBAND_ENABLED'], default: false) + end + def schedule_type ENV['SCHEDULE_TYPE'] end diff --git a/qa/qa/specs/spec_helper.rb b/qa/qa/specs/spec_helper.rb index 2a2754133e677..915d7acff1995 100644 --- a/qa/qa/specs/spec_helper.rb +++ b/qa/qa/specs/spec_helper.rb @@ -37,6 +37,7 @@ config.add_formatter QA::Support::Formatters::QuarantineFormatter config.add_formatter QA::Support::Formatters::FeatureFlagFormatter config.add_formatter QA::Support::Formatters::TestMetricsFormatter if QA::Runtime::Env.running_in_ci? + config.add_formatter QA::Support::Formatters::CoverbandFormatter if QA::Runtime::Env.coverband_enabled? config.example_status_persistence_file_path = ENV.fetch('RSPEC_LAST_RUN_RESULTS_FILE', 'tmp/examples.txt') diff --git a/qa/qa/support/formatters/coverband_formatter.rb b/qa/qa/support/formatters/coverband_formatter.rb new file mode 100644 index 0000000000000..d4c669744d1f7 --- /dev/null +++ b/qa/qa/support/formatters/coverband_formatter.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'json' + +module QA + module Support + module Formatters + # RSpec formatter to map E2E specs to diff files + class CoverbandFormatter < ::RSpec::Core::Formatters::BaseFormatter + include Support::API + + COVERAGE_API_PATH = '/api/v4/internal/coverage' + + def initialize(output) + super + @test_mapping = Hash.new { |hsh, key| hsh[key] = [] } + @logger = Runtime::Logger.logger + @headers_access_token = { "PRIVATE-TOKEN" => Runtime::Env.admin_personal_access_token } + end + + ::RSpec::Core::Formatters.register( + self, + :example_started, + :example_finished, + :stop + ) + + # Runs at the end of suite + # + # @param [RSpec::Core::Notifications::ExamplesNotification] notification + # @return [void] + def stop(_notification) + logger.info("Saving test coverage mapping json file") + + save_test_mapping + end + + # Example start event + def example_started(_example_notification) + QA::Support::Retrier.retry_until(max_attempts: 5, sleep_interval: 1, message: "Retry clear coverage") do + resp = delete("#{Runtime::Scenario.gitlab_address}#{COVERAGE_API_PATH}", + headers: headers_access_token) + + logger.error("Failed to clear coverage, code: #{resp.code}, body: #{resp.body}") if resp.code != 200 + + resp.code == 200 + end + logger.debug("Cleared coverage data before example starts") + rescue StandardError => e + logger.error("Failed to clear coverage. Exception trace: #{e}") + end + + # Example finish event + def example_finished(example_notification) + cov_resp = nil + QA::Support::Retrier.retry_until(max_attempts: 10, sleep_interval: 2, message: "Retry fetch coverage") do + cov_resp = get("#{Runtime::Scenario.gitlab_address}/api/v4/internal/coverage", + headers: headers_access_token) + + if cov_resp.code != 200 + logger.error("Fetching coverage data failed, code: #{cov_resp.code}, body: #{cov_resp.body}") + end + + cov_resp.code == 200 && !JSON.parse(cov_resp.body).empty? + end + + example_path = example_notification.example.metadata[:location] + test_mapping[example_path] = JSON.parse(cov_resp.body) unless example_failed?(example_notification) + logger.debug("Coverage paths were stored in mapping hash") + rescue StandardError => e + logger.error("Failed to fetch coverage mapping. Trace: #{e}") + end + + def example_failed?(example_notification) + example_notification.example.execution_result.status == :failed + end + + # Save coverage test mapping file + # + # @return [void] + def save_test_mapping + file = "tmp/test-code-paths-mapping-#{ENV['CI_JOB_NAME_SLUG'] || 'local'}-#{SecureRandom.hex(6)}.json" + # To write two different files in case of failed specs being retried + + File.write(file, test_mapping.to_json) + logger.debug("Saved test code paths mapping to #{file}") + rescue StandardError => e + logger.error("Failed to save test code paths mapping, error: #{e}") + end + + private + + attr_reader :test_mapping, :logger, :headers_access_token + end + end + end +end diff --git a/qa/qa/tools/ci/export_code_paths_mapping.rb b/qa/qa/tools/ci/export_code_paths_mapping.rb new file mode 100644 index 0000000000000..2d681fe56d609 --- /dev/null +++ b/qa/qa/tools/ci/export_code_paths_mapping.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "fog/google" + +module QA + module Tools + module Ci + class ExportCodePathsMapping + include Helpers + + PROJECT = "gitlab-qa-resources" + BUCKET = "code-path-mappings" + + def self.export(mapping_files_glob) + new(mapping_files_glob).export + end + + def initialize(mapping_files_glob) + @mapping_files_glob = mapping_files_glob + end + + # Export code path mappings to GCP + # + # @return [void] + def export + mapping_files = Dir.glob(mapping_files_glob) + return logger.warn("No files matched pattern") if mapping_files.empty? + + logger.info("Number of mapping files found: #{mapping_files.size}") + + mapping_data = mapping_files.flat_map { |file| JSON.parse(File.read(file)) }.reduce(:merge!) + file = "test-code-paths-mapping-merged-pipeline-#{ENV['CI_PIPELINE_ID'] || 'local'}.json" + File.write(file, mapping_data.to_json) && logger.debug("Saved test code paths mapping to #{file}") + upload_to_gcs(file, mapping_data) + end + + private + + attr_reader :mapping_files_glob + + def upload_to_gcs(file_name, mapping_data) + client.put_object(BUCKET, file_name, JSON.pretty_generate(mapping_data)) + rescue StandardError => e + logger.error("Failed to upload code paths mapping to GCS. Error: #{e}") + end + + # GCS client + # + # @return [Fog::Storage::GoogleJSON] + def client + @client ||= Fog::Storage::Google.new(google_project: PROJECT, **gcs_credentials) + end + + # GCS credentials json + # + # @return [Hash] + def gcs_credentials + json_key = ENV.fetch("QA_CODE_PATH_MAPPINGS_GCS_CREDENTIALS") do + raise "QA_CODE_PATH_MAPPINGS_GCS_CREDENTIALS env variable is required!" + end + + return { google_json_key_location: json_key } if File.exist?(json_key) + + { google_json_key_string: json_key } + end + end + end + end +end diff --git a/qa/spec/support/formatters/coverband_formatter_spec.rb b/qa/spec/support/formatters/coverband_formatter_spec.rb new file mode 100644 index 0000000000000..a10a44a65f557 --- /dev/null +++ b/qa/spec/support/formatters/coverband_formatter_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +describe QA::Support::Formatters::CoverbandFormatter do + include QA::Support::Helpers::StubEnv + + let(:formatter) { described_class.new(StringIO.new) } + + let(:rspec_example_notification) do + instance_double(RSpec::Core::Notifications::ExampleNotification, example: rspec_example) + end + + # rubocop:disable RSpec/VerifiedDoubles -- Custom object + + let(:rspec_example) do + double( + RSpec::Core::Example, + file_path: 'create_issue_spec.rb', + execution_result: instance_double(RSpec::Core::Example::ExecutionResult, status: status), + metadata: { + testcase: 'testcase', + full_description: "Plan", + location: "./qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb:5" + } + ) + end + + # rubocop:enable RSpec/VerifiedDoubles + + let(:gitlab_address) { 'http://gitlab.test.com' } + let(:api_path) { "#{gitlab_address}/api/v4/internal/coverage" } + + let(:status) { :failed } + let(:token_header) { { "PRIVATE-TOKEN" => 'token' } } + let(:api_response) do + instance_double( + ::RestClient::Response + ) + end + + let(:non_empty_response) do + '{"test mapping":1}' + end + + let(:mapping) do + { "./qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb:5": { "test mapping": 1 } } + end + + before do + stub_env('GITLAB_QA_ADMIN_ACCESS_TOKEN', 'token') + end + + context 'with example_started' do + before do + allow(QA::Runtime::Scenario).to receive(:gitlab_address).and_return(gitlab_address) + allow(api_response).to receive(:body).and_return({}) + allow(::RestClient::Request).to receive(:execute).and_return(api_response) + end + + context 'when success response' do + before do + allow(api_response).to receive(:code).and_return(200) + end + + it 'logs coverage cleared', :aggregate_failures do + expect(QA::Runtime::Logger.logger).to receive(:debug).with("Cleared coverage data before example starts").once + formatter.example_started(rspec_example_notification) + end + end + + context 'with failure response' do + before do + allow(api_response).to receive(:code).and_return(401) + allow(QA::Support::Retrier).to receive(:retry_until).and_wrap_original do |method| + method.call(max_attempts: 1) + end + end + + it 'logs error message' do + expect(QA::Runtime::Logger.logger).to receive(:error).with(/Failed to clear coverage.*/).once + formatter.example_started(rspec_example_notification) + end + end + end + + context 'when example finished' do + before do + allow(QA::Runtime::Scenario).to receive(:gitlab_address).and_return(gitlab_address) + allow(::RestClient::Request).to receive(:execute).and_return(api_response) + end + + context 'with success response and non empty coverage' do + let(:status) { :passed } + + before do + allow(api_response).to receive(:code).and_return(200) + allow(api_response).to receive(:body).and_return(non_empty_response) + end + + it 'logs success message and does not log any errors' do + expect(QA::Runtime::Logger.logger).not_to receive(:error) + expect(QA::Runtime::Logger.logger).to receive(:debug).with("Coverage paths were stored in mapping hash").once + formatter.example_finished(rspec_example_notification) + end + end + + context 'with failure response' do + let(:status) { :passed } + + before do + allow(api_response).to receive(:code).and_return(401) + allow(api_response).to receive(:body).and_return({}) + allow(QA::Support::Retrier).to receive(:retry_until).and_wrap_original do |method| + method.call(max_attempts: 1) + end + end + + it 'logs error message' do + expect(QA::Runtime::Logger.logger).to receive(:error).with(/Failed to fetch coverage mapping.*/).once + formatter.example_finished(rspec_example_notification) + end + end + end + + context 'when save_test_mapping is called' do + before do + stub_env('CI_JOB_NAME_SLUG', 'job-name') + end + + let(:file_name_pattern) { "test-code-paths-mapping-job-name" } + + context 'with mapping data present' do + before do + allow(formatter).to receive(:test_mapping).and_return(mapping) + end + + it 'writes to file' do + expect(::File).to receive(:write).with(/#{file_name_pattern}/, mapping.to_json).once + expect(QA::Runtime::Logger.logger).to receive(:debug).with(/Saved test code paths mapping to.*/).once + formatter.save_test_mapping + end + end + + context 'when writing to file throws an error' do + before do + allow(::File).to receive(:write).and_raise(StandardError) + end + + it 'raises an error' do + expect(QA::Runtime::Logger.logger).to receive(:error) + .with(/Failed to save test code paths mapping, error:.*/).once + formatter.save_test_mapping + end + end + end +end diff --git a/qa/spec/tools/ci/export_code_paths_mapping_spec.rb b/qa/spec/tools/ci/export_code_paths_mapping_spec.rb new file mode 100644 index 0000000000000..1b9b737f73929 --- /dev/null +++ b/qa/spec/tools/ci/export_code_paths_mapping_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +RSpec.describe QA::Tools::Ci::ExportCodePathsMapping do + include QA::Support::Helpers::StubEnv + + let(:glob) { "test_code_paths/*.json" } + let(:file_paths) { ["/test_code/test_code_path_mappings.json"] } + let(:logger) { instance_double("Logger", info: true, warn: true, debug: true) } + let(:gcs_client_options) { { force: true, content_type: 'application/json' } } + let(:gcs_client) { double("Fog::Storage::GoogleJSON::Real", put_object: nil) } # rubocop:disable RSpec/VerifiedDoubles -- Class has `put_object` method but is not getting verified + let(:gcs_project) { 'gitlab-qa-resources' } + let(:gcs_bucket_name) { 'code-path-mappings' } + let(:gcs_credentials) { 'code-path-mappings-gcs-credentials' } + let(:mapping_json) { code_path_mappings_data.to_json } + let(:code_path_mappings_data) do + { + "path_to_spec:rb": ['lib/model.rb'] + } + end + + let(:pretty_generated_mapping_json) do + JSON.pretty_generate(code_path_mappings_data) + end + + before do + allow(Fog::Storage::Google).to receive(:new) + .with(google_project: gcs_project, + google_json_key_string: gcs_credentials) + .and_return(gcs_client) + allow(Gitlab::QA::TestLogger).to receive(:logger) { logger } + allow(Dir).to receive(:glob).with(glob) { file_paths } + allow(::File).to receive(:read).with(anything).and_return(code_path_mappings_data.to_json) + stub_env('QA_CODE_PATH_MAPPINGS_GCS_CREDENTIALS', gcs_credentials) + end + + context "with mapping files present" do + it "exports mapping json to GCS and writes it as job artifact", :aggregate_failures do + expect(logger).to receive(:info).with("Number of mapping files found: #{file_paths.size}") + expect(::File).to receive(:write).with(String, mapping_json).once + expect(gcs_client).to receive(:put_object).with(gcs_bucket_name, + String, pretty_generated_mapping_json) + described_class.export(glob) + end + end + + context "with no mapping files present" do + let(:file_paths) { [] } + + it "exits without any exception raised but logs the error", :aggregate_failures do + expect(logger).to receive(:warn).with(/No files matched pattern/).once + expect(::File).not_to receive(:write) + described_class.export(glob) + end + end +end diff --git a/qa/tasks/ci.rake b/qa/tasks/ci.rake index 887b90b919cb0..1c58339193f32 100644 --- a/qa/tasks/ci.rake +++ b/qa/tasks/ci.rake @@ -83,4 +83,11 @@ namespace :ci do QA::Tools::Ci::TestMetrics.export(args[:glob]) end + + desc "Export code paths mapping to GCP" + task :export_code_paths_mapping, [:glob] do |_, args| + raise("Code paths mapping JSON glob pattern is required") unless args[:glob] + + QA::Tools::Ci::ExportCodePathsMapping.export(args[:glob]) + end end diff --git a/scripts/generate-e2e-pipeline b/scripts/generate-e2e-pipeline index ff56e065126df..d6e7107ac66a6 100755 --- a/scripts/generate-e2e-pipeline +++ b/scripts/generate-e2e-pipeline @@ -49,6 +49,7 @@ variables: QA_SUITES: "$QA_SUITES" QA_TESTS: "$QA_TESTS" KNAPSACK_TEST_FILE_PATTERN: "$KNAPSACK_TEST_FILE_PATTERN" + COVERBAND_ENABLED: "$COVERBAND_ENABLED" YML ) diff --git a/spec/requests/api/internal/coverage_spec.rb b/spec/requests/api/internal/coverage_spec.rb new file mode 100644 index 0000000000000..10731231761df --- /dev/null +++ b/spec/requests/api/internal/coverage_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Internal::Coverage, feature_category: :code_testing do + let(:admin) { create(:admin) } + + before_all do + ::API::API.mount ::API::Internal::Coverage + end + + describe '/internal/coverage' do + let(:path) { "/internal/coverage" } + + context 'when user is not admin' do + it 'GET returns 401' do + get api(path) + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'DELETE returns 401' do + delete api(path) + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when user is admin' do + let(:coverage_hash) do + { "./lib/gitlab/database/load_balancing/load_balancer.rb" => + { "first_updated_at" => 1718070708, + "last_updated_at" => 1718073948, + "file_hash" => "1d6368d5806dba4d4af79450d0df9b72" } } + end + + let(:resp) { coverage_hash.keys } + + before do + stub_const('Coverband', Class.new) + allow(Coverband).to receive_message_chain(:configuration, :store, :coverage).and_return(coverage_hash) + allow(Coverband).to receive_message_chain(:configuration, :store, :clear!).and_return({}) + end + + it 'GET returns 200', :aggregate_failures do + get api(path.to_s, admin, admin_mode: true) + expect(response).to have_gitlab_http_status(:success) + expect(json_response).to eq(resp) + end + + it 'DELETE returns 200' do + delete api(path.to_s, admin, admin_mode: true) + expect(response).to have_gitlab_http_status(:success) + end + end + end +end -- GitLab