diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c4469ea98a303ee094330f4dfb882dd39f077d51..9b9b217126b13c292e7957bd424c0ac224c914ee 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -165,7 +165,7 @@ variables: RSPEC_PACKED_TESTS_MAPPING_PATH: crystalball/packed-mapping.json RSPEC_PROFILING_FOLDER_PATH: rspec/profiling RSPEC_TESTS_MAPPING_PATH: crystalball/mapping.json - RSPEC_FAST_QUARANTINE_PATH: rspec/fast_quarantine-gitlab.txt + RSPEC_FAST_QUARANTINE_LOCAL_PATH: rspec/fast_quarantine-gitlab.txt TMP_TEST_FOLDER: "${CI_PROJECT_DIR}/tmp/tests" TMP_TEST_GITLAB_WORKHORSE_PATH: "${TMP_TEST_FOLDER}/${GITLAB_WORKHORSE_FOLDER}" diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml index 17fd8ae38f914a83d71deec1015372abf3e18f08..a4041f771d9b98911fcaaf0a859b873a143e8ff2 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -346,7 +346,7 @@ rspec:flaky-tests-report: # so we use `dependencies` here. dependencies: !reference ["rspec:coverage", "dependencies"] variables: - SKIPPED_FLAKY_TESTS_REPORT_PATH: rspec/flaky/skipped_flaky_tests_report.txt + SKIPPED_TESTS_REPORT_PATH: rspec/skipped_tests_report.txt RETRIED_TESTS_REPORT_PATH: rspec/flaky/retried_tests_report.txt before_script: - source scripts/utils.sh diff --git a/doc/development/testing_guide/flaky_tests.md b/doc/development/testing_guide/flaky_tests.md index d8bdc56f26556808ec9047c80e9e545ba2bcdfdb..40be7a7d0944acee9accbeb5f027553228def3d6 100644 --- a/doc/development/testing_guide/flaky_tests.md +++ b/doc/development/testing_guide/flaky_tests.md @@ -193,6 +193,8 @@ This means it is skipped unless run with `--tag quarantine`: bin/rspec --tag quarantine ``` +After the long-term quarantining MR has reached production, you should revert the fast-quarantine MR you created earlier. + ### Jest For Jest specs, you can use the `.skip` method along with the `eslint-disable-next-line` comment to disable the `jest/no-disabled-tests` ESLint rule and include the issue URL. Here's an example: diff --git a/scripts/rspec_helpers.sh b/scripts/rspec_helpers.sh index b64e6ed6f58b53307438bbd4c7bbf4d09bee946a..4e73bf48021c560108917d2c98cef75c9063ac6d 100644 --- a/scripts/rspec_helpers.sh +++ b/scripts/rspec_helpers.sh @@ -13,9 +13,9 @@ function retrieve_tests_metadata() { echo "{}" > "${FLAKY_RSPEC_SUITE_REPORT_PATH}" fi - if [[ ! -f "${RSPEC_FAST_QUARANTINE_PATH}" ]]; then - curl --location -o "${RSPEC_FAST_QUARANTINE_PATH}" "https://gitlab-org.gitlab.io/quality/engineering-productivity/fast-quarantine/${RSPEC_FAST_QUARANTINE_PATH}" || - echo "" > "${RSPEC_FAST_QUARANTINE_PATH}" + if [[ ! -f "${RSPEC_FAST_QUARANTINE_LOCAL_PATH}" ]]; then + curl --location -o "${RSPEC_FAST_QUARANTINE_LOCAL_PATH}" "https://gitlab-org.gitlab.io/quality/engineering-productivity/fast-quarantine/${RSPEC_FAST_QUARANTINE_LOCAL_PATH}" || + echo "" > "${RSPEC_FAST_QUARANTINE_LOCAL_PATH}" fi } @@ -139,7 +139,7 @@ function debug_rspec_variables() { echoinfo "FLAKY_RSPEC_SUITE_REPORT_PATH: ${FLAKY_RSPEC_SUITE_REPORT_PATH:-}" echoinfo "FLAKY_RSPEC_REPORT_PATH: ${FLAKY_RSPEC_REPORT_PATH:-}" echoinfo "NEW_FLAKY_RSPEC_REPORT_PATH: ${NEW_FLAKY_RSPEC_REPORT_PATH:-}" - echoinfo "SKIPPED_FLAKY_TESTS_REPORT_PATH: ${SKIPPED_FLAKY_TESTS_REPORT_PATH:-}" + echoinfo "SKIPPED_TESTS_REPORT_PATH: ${SKIPPED_TESTS_REPORT_PATH:-}" echoinfo "CRYSTALBALL: ${CRYSTALBALL:-}" @@ -205,7 +205,7 @@ function rspec_paralellized_job() { export KNAPSACK_TEST_FILE_PATTERN=$(ruby -r./tooling/quality/test_level.rb -e "puts Quality::TestLevel.new(${spec_folder_prefixes}).pattern(:${test_level})") export FLAKY_RSPEC_REPORT_PATH="${rspec_flaky_folder_path}all_${report_name}_report.json" export NEW_FLAKY_RSPEC_REPORT_PATH="${rspec_flaky_folder_path}new_${report_name}_report.json" - export SKIPPED_FLAKY_TESTS_REPORT_PATH="${rspec_flaky_folder_path}skipped_flaky_tests_${report_name}_report.txt" + export SKIPPED_TESTS_REPORT_PATH="rspec/skipped_tests_${report_name}.txt" if [[ -d "ee/" ]]; then export KNAPSACK_GENERATE_REPORT="true" @@ -408,7 +408,7 @@ function generate_flaky_tests_reports() { mkdir -p ${rspec_flaky_folder_path} - find ${rspec_flaky_folder_path} -type f -name 'skipped_flaky_tests_*_report.txt' -exec cat {} + >> "${SKIPPED_FLAKY_TESTS_REPORT_PATH}" + find ${rspec_flaky_folder_path} -type f -name 'skipped_tests_*.txt' -exec cat {} + >> "${SKIPPED_TESTS_REPORT_PATH}" find ${rspec_flaky_folder_path} -type f -name 'retried_tests_*_report.txt' -exec cat {} + >> "${RETRIED_TESTS_REPORT_PATH}" cleanup_individual_job_reports diff --git a/spec/support/fast_quarantine.rb b/spec/support/fast_quarantine.rb index dd805f6ec285356c3acc4458cae0b29319f52820..18d4df887a3fa5d50031b9f4fde81de7fb70b976 100644 --- a/spec/support/fast_quarantine.rb +++ b/spec/support/fast_quarantine.rb @@ -1,50 +1,36 @@ # frozen_string_literal: true -return unless ENV['CI'] return if ENV['FAST_QUARANTINE'] == "false" return if ENV['CI_MERGE_REQUEST_LABELS'].to_s.include?('pipeline:run-flaky-tests') -require_relative '../../tooling/rspec_flaky/config' +require_relative '../../tooling/lib/tooling/fast_quarantine' -# rubocop:disable Style/GlobalVars RSpec.configure do |config| - $fast_quarantined_entity_identifiers = begin - raise "#{ENV['RSPEC_FAST_QUARANTINE_PATH']} doesn't exist" unless File.exist?(ENV['RSPEC_FAST_QUARANTINE_PATH']) - - quarantined_entity_identifiers = File.read(ENV['RSPEC_FAST_QUARANTINE_PATH']).lines - quarantined_entity_identifiers.compact! - quarantined_entity_identifiers.map! do |quarantined_entity_identifier| - quarantined_entity_identifier.delete_prefix('./').strip - end - rescue => e # rubocop:disable Style/RescueStandardError - puts e - [] - end - $skipped_tests = [] + fast_quarantine_local_path = ENV.fetch('RSPEC_FAST_QUARANTINE_LOCAL_PATH', 'rspec/fast_quarantine-gitlab.txt') + fast_quarantine_path = ENV.fetch( + 'RSPEC_FAST_QUARANTINE_PATH', + File.expand_path("../../#{fast_quarantine_local_path}", __dir__) + ) + fast_quarantine = Tooling::FastQuarantine.new(fast_quarantine_path: fast_quarantine_path) + skipped_examples = [] config.around do |example| - fast_quarantined_entity_identifier = $fast_quarantined_entity_identifiers.find do |quarantined_entity_identifier| - case quarantined_entity_identifier - when /^.+_spec\.rb\[[\d:]+\]$/ # example id, e.g. spec/tasks/gitlab/usage_data_rake_spec.rb[1:5:2:1] - example.id == "./#{quarantined_entity_identifier}" - else # whole file, e.g. ee/spec/features/boards/swimlanes/epics_swimlanes_sidebar_spec.rb - example.metadata[:rerun_file_path] == "./#{quarantined_entity_identifier}" - end - end - - if fast_quarantined_entity_identifier - puts "Skipping #{example.id} '#{example.full_description}' because it's been fast-quarantined with '#{fast_quarantined_entity_identifier}'." - $skipped_tests << example.id + if fast_quarantine.skip_example?(example) + skipped_examples << example.id + skip "Skipping #{example.id} because it's been fast-quarantined." else example.run end end config.after(:suite) do - next unless RspecFlaky::Config.skipped_flaky_tests_report_path - next if $skipped_tests.empty? + next if skipped_examples.empty? + + skipped_tests_report_path = ENV.fetch( + 'SKIPPED_TESTS_REPORT_PATH', + File.expand_path("../../rspec/flaky/skipped_tests.txt", __dir__) + ) - File.write(RspecFlaky::Config.skipped_flaky_tests_report_path, "#{ENV['CI_JOB_URL']}\n#{$skipped_tests.join("\n")}\n\n") + File.write(skipped_tests_report_path, "#{ENV.fetch('CI_JOB_URL', 'local-run')}\n#{skipped_examples.join("\n")}\n\n") end end -# rubocop:enable Style/GlobalVars diff --git a/spec/tooling/lib/tooling/fast_quarantine_spec.rb b/spec/tooling/lib/tooling/fast_quarantine_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bb60a335ce239643ec3662cfa1d797589b63ef7b --- /dev/null +++ b/spec/tooling/lib/tooling/fast_quarantine_spec.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require_relative '../../../../tooling/lib/tooling/fast_quarantine' +require 'tempfile' + +RSpec.describe Tooling::FastQuarantine, feature_category: :tooling do + attr_accessor :fast_quarantine_file + + around do |example| + self.fast_quarantine_file = Tempfile.new('fast_quarantine_file') + + # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/ + # Tempfile.html#class-Tempfile-label-Explicit+close + begin + example.run + ensure + fast_quarantine_file.close + fast_quarantine_file.unlink + end + end + + let(:fast_quarantine_path) { fast_quarantine_file.path } + let(:fast_quarantine_file_content) { '' } + let(:instance) do + described_class.new(fast_quarantine_path: fast_quarantine_path) + end + + before do + File.write(fast_quarantine_path, fast_quarantine_file_content) + end + + describe '#initialize' do + context 'when fast_quarantine_path does not exist' do + it 'prints a warning' do + allow(File).to receive(:exist?).and_return(false) + + expect { instance }.to output("#{fast_quarantine_path} doesn't exist!\n").to_stderr + end + end + + context 'when fast_quarantine_path exists' do + it 'does not raise an error' do + expect { instance }.not_to raise_error + end + end + end + + describe '#identifiers' do + before do + allow(File).to receive(:read).and_call_original + end + + context 'when the fast quarantine file is empty' do + let(:fast_quarantine_file_content) { '' } + + it 'returns []' do + expect(instance.identifiers).to eq([]) + end + end + + context 'when the fast quarantine file is not empty' do + let(:fast_quarantine_file_content) { "./spec/foo_spec.rb\nspec/foo_spec.rb:42\n./spec/baz_spec.rb[1:2:3]" } + + it 'returns parsed and sanitized lines' do + expect(instance.identifiers).to eq(%w[ + spec/foo_spec.rb + spec/foo_spec.rb:42 + spec/baz_spec.rb[1:2:3] + ]) + end + + context 'when reading the file raises an error' do + before do + allow(File).to receive(:read).with(fast_quarantine_path).and_raise('') + end + + it 'returns []' do + expect(instance.identifiers).to eq([]) + end + end + + describe 'memoization' do + it 'memoizes the identifiers list' do + expect(File).to receive(:read).with(fast_quarantine_path).once.and_call_original + + instance.identifiers + + # calling #identifiers again doesn't call File.read + instance.identifiers + end + end + end + end + + describe '#skip_example?' do + let(:fast_quarantine_file_content) { "./spec/foo_spec.rb\nspec/bar_spec.rb:42\n./spec/baz_spec.rb[1:2:3]" } + let(:example_id) { './spec/foo_spec.rb[1:2:3]' } + let(:example_metadata) { {} } + let(:example) { instance_double(RSpec::Core::Example, id: example_id, metadata: example_metadata) } + + describe 'skipping example by id' do + let(:example_id) { './spec/baz_spec.rb[1:2:3]' } + + it 'skips example by id' do + expect(instance.skip_example?(example)).to be_truthy + end + end + + describe 'skipping example by line' do + context 'when example location matches' do + let(:example_metadata) do + { location: './spec/bar_spec.rb:42' } + end + + it 'skips example by line' do + expect(instance.skip_example?(example)).to be_truthy + end + end + + context 'when example group location matches' do + let(:example_metadata) do + { + example_group: { location: './spec/bar_spec.rb:42' } + } + end + + it 'skips example by line' do + expect(instance.skip_example?(example)).to be_truthy + end + end + + context 'when nested parent example group location matches' do + let(:example_metadata) do + { + example_group: { + parent_example_group: { + parent_example_group: { + parent_example_group: { location: './spec/bar_spec.rb:42' } + } + } + } + } + end + + it 'skips example by line' do + expect(instance.skip_example?(example)).to be_truthy + end + end + end + + describe 'skipping example by file' do + context 'when example file_path matches' do + let(:example_metadata) do + { file_path: './spec/foo_spec.rb' } + end + + it 'skips example by file' do + expect(instance.skip_example?(example)).to be_truthy + end + end + + context 'when example group file_path matches' do + let(:example_metadata) do + { + example_group: { file_path: './spec/foo_spec.rb' } + } + end + + it 'skips example by file' do + expect(instance.skip_example?(example)).to be_truthy + end + end + + context 'when nested parent example group file_path matches' do + let(:example_metadata) do + { + example_group: { + parent_example_group: { + parent_example_group: { + parent_example_group: { file_path: './spec/foo_spec.rb' } + } + } + } + } + end + + it 'skips example by file' do + expect(instance.skip_example?(example)).to be_truthy + end + end + end + end +end diff --git a/spec/tooling/rspec_flaky/config_spec.rb b/spec/tooling/rspec_flaky/config_spec.rb index c95e5475d66a81297f226eb61b297709d3543a6c..63f42d7c6cc62e3e8dc260a860aa9c0cbc1df8a9 100644 --- a/spec/tooling/rspec_flaky/config_spec.rb +++ b/spec/tooling/rspec_flaky/config_spec.rb @@ -14,7 +14,6 @@ stub_env('FLAKY_RSPEC_SUITE_REPORT_PATH', nil) stub_env('FLAKY_RSPEC_REPORT_PATH', nil) stub_env('NEW_FLAKY_RSPEC_REPORT_PATH', nil) - stub_env('SKIPPED_FLAKY_TESTS_REPORT_PATH', nil) # Ensure the behavior is the same locally and on CI (where Rails is defined since we run this test as part of the whole suite), i.e. Rails isn't defined allow(described_class).to receive(:rails_path).and_wrap_original do |method, path| path @@ -104,22 +103,4 @@ end end end - - describe '.skipped_flaky_tests_report_path' do - context "when ENV['SKIPPED_FLAKY_TESTS_REPORT_PATH'] is not set" do - it 'returns the default path' do - expect(described_class.skipped_flaky_tests_report_path).to eq('rspec/flaky/skipped_flaky_tests_report.txt') - end - end - - context "when ENV['SKIPPED_FLAKY_TESTS_REPORT_PATH'] is set" do - before do - stub_env('SKIPPED_FLAKY_TESTS_REPORT_PATH', 'foo/skipped_flaky_tests_report.txt') - end - - it 'returns the value of the env variable' do - expect(described_class.skipped_flaky_tests_report_path).to eq('foo/skipped_flaky_tests_report.txt') - end - end - end end diff --git a/tooling/lib/tooling/fast_quarantine.rb b/tooling/lib/tooling/fast_quarantine.rb new file mode 100644 index 0000000000000000000000000000000000000000..a0dc8bc460ba0d4ca5c80c5d5a6813e821e02265 --- /dev/null +++ b/tooling/lib/tooling/fast_quarantine.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Tooling + class FastQuarantine + def initialize(fast_quarantine_path:) + warn "#{fast_quarantine_path} doesn't exist!" unless File.exist?(fast_quarantine_path.to_s) + + @fast_quarantine_path = fast_quarantine_path + end + + def identifiers + @identifiers ||= begin + quarantined_entity_identifiers = File.read(fast_quarantine_path).lines + quarantined_entity_identifiers.compact! + quarantined_entity_identifiers.map! do |quarantined_entity_identifier| + quarantined_entity_identifier.delete_prefix('./').strip + end + rescue => e # rubocop:disable Style/RescueStandardError + $stdout.puts e + [] + end + end + + def skip_example?(example) + identifiers.find do |quarantined_entity_identifier| + case quarantined_entity_identifier + when /^.+_spec\.rb\[[\d:]+\]$/ # example id, e.g. spec/tasks/gitlab/usage_data_rake_spec.rb[1:5:2:1] + example.id == "./#{quarantined_entity_identifier}" + when /^.+_spec\.rb:\d+$/ # file + line, e.g. spec/tasks/gitlab/usage_data_rake_spec.rb:42 + fetch_metadata_from_ancestors(example, :location) + .any?("./#{quarantined_entity_identifier}") + when /^.+_spec\.rb$/ # whole file, e.g. ee/spec/features/boards/swimlanes/epics_swimlanes_sidebar_spec.rb + fetch_metadata_from_ancestors(example, :file_path) + .any?("./#{quarantined_entity_identifier}") + end + end + end + + private + + attr_reader :fast_quarantine_path + + def fetch_metadata_from_ancestors(example, attribute) + metadata = [example.metadata[attribute]] + example_group = example.metadata[:example_group] + + loop do + break if example_group.nil? + + metadata << example_group[attribute] + example_group = example_group[:parent_example_group] + end + + metadata + end + end +end diff --git a/tooling/rspec_flaky/config.rb b/tooling/rspec_flaky/config.rb index 36e356715876995f4a677cbe11b6e3a44a4b8f46..0e36e985aad59ebb9b6907642b6092c8134c7ede 100644 --- a/tooling/rspec_flaky/config.rb +++ b/tooling/rspec_flaky/config.rb @@ -18,10 +18,6 @@ def self.new_flaky_examples_report_path ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] || rails_path("rspec/flaky/new-report.json") end - def self.skipped_flaky_tests_report_path - ENV['SKIPPED_FLAKY_TESTS_REPORT_PATH'] || rails_path("rspec/flaky/skipped_flaky_tests_report.txt") - end - def self.rails_path(path) return path unless defined?(Rails)