Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/tooling')
-rw-r--r--spec/tooling/danger/changelog_spec.rb182
-rw-r--r--spec/tooling/danger/helper_spec.rb127
-rw-r--r--spec/tooling/rspec_flaky/config_spec.rb106
-rw-r--r--spec/tooling/rspec_flaky/example_spec.rb92
-rw-r--r--spec/tooling/rspec_flaky/flaky_example_spec.rb185
-rw-r--r--spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb74
-rw-r--r--spec/tooling/rspec_flaky/listener_spec.rb227
-rw-r--r--spec/tooling/rspec_flaky/report_spec.rb135
8 files changed, 1032 insertions, 96 deletions
diff --git a/spec/tooling/danger/changelog_spec.rb b/spec/tooling/danger/changelog_spec.rb
index c0eca67ce92..8d056b8a78e 100644
--- a/spec/tooling/danger/changelog_spec.rb
+++ b/spec/tooling/danger/changelog_spec.rb
@@ -2,50 +2,78 @@
require_relative 'danger_spec_helper'
+require_relative '../../../tooling/danger/helper'
require_relative '../../../tooling/danger/changelog'
RSpec.describe Tooling::Danger::Changelog do
include DangerSpecHelper
- let(:added_files) { nil }
- let(:fake_git) { double('fake-git', added_files: added_files) }
+ let(:change_class) { Tooling::Danger::Helper::Change }
+ let(:changes_class) { Tooling::Danger::Helper::Changes }
+ let(:changes) { changes_class.new([]) }
- let(:mr_labels) { nil }
- let(:mr_json) { nil }
- let(:fake_gitlab) { double('fake-gitlab', mr_labels: mr_labels, mr_json: mr_json) }
+ let(:mr_labels) { [] }
+ let(:sanitize_mr_title) { 'Fake Title' }
- let(:changes_by_category) { nil }
- let(:sanitize_mr_title) { nil }
- let(:ee?) { false }
- let(:fake_helper) { double('fake-helper', changes_by_category: changes_by_category, sanitize_mr_title: sanitize_mr_title, ee?: ee?) }
+ let(:fake_helper) { double('fake-helper', changes: changes, mr_iid: 1234, mr_title: sanitize_mr_title, mr_labels: mr_labels) }
let(:fake_danger) { new_fake_danger.include(described_class) }
- subject(:changelog) { fake_danger.new(git: fake_git, gitlab: fake_gitlab, helper: fake_helper) }
+ subject(:changelog) { fake_danger.new(helper: fake_helper) }
+
+ describe '#required_reasons' do
+ subject { changelog.required_reasons }
+
+ context "added files contain a migration" do
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :migration)]) }
+
+ it { is_expected.to include(:db_changes) }
+ end
+
+ context "removed files contains a feature flag" do
+ let(:changes) { changes_class.new([change_class.new('foo', :deleted, :feature_flag)]) }
+
+ it { is_expected.to include(:feature_flag_removed) }
+ end
+
+ context "added files do not contain a migration" do
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :frontend)]) }
+
+ it { is_expected.to be_empty }
+ end
+
+ context "removed files do not contain a feature flag" do
+ let(:changes) { changes_class.new([change_class.new('foo', :deleted, :backend)]) }
+
+ it { is_expected.to be_empty }
+ end
+ end
describe '#required?' do
subject { changelog.required? }
context 'added files contain a migration' do
- [
- 'db/migrate/20200000000000_new_migration.rb',
- 'db/post_migrate/20200000000000_new_migration.rb'
- ].each do |file_path|
- let(:added_files) { [file_path] }
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :migration)]) }
- it { is_expected.to be_truthy }
- end
+ it { is_expected.to be_truthy }
+ end
+
+ context "removed files contains a feature flag" do
+ let(:changes) { changes_class.new([change_class.new('foo', :deleted, :feature_flag)]) }
+
+ it { is_expected.to be_truthy }
end
context 'added files do not contain a migration' do
- [
- 'app/models/model.rb',
- 'app/assets/javascripts/file.js'
- ].each do |file_path|
- let(:added_files) { [file_path] }
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :frontend)]) }
- it { is_expected.to be_falsey }
- end
+ it { is_expected.to be_falsey }
+ end
+
+ context "removed files do not contain a feature flag" do
+ let(:changes) { changes_class.new([change_class.new('foo', :deleted, :backend)]) }
+
+ it { is_expected.to be_falsey }
end
end
@@ -58,8 +86,7 @@ RSpec.describe Tooling::Danger::Changelog do
subject { changelog.optional? }
context 'when MR contains only categories requiring no changelog' do
- let(:changes_by_category) { { category_without_changelog => nil } }
- let(:mr_labels) { [] }
+ let(:changes) { changes_class.new([change_class.new('foo', :modified, category_without_changelog)]) }
it 'is falsey' do
is_expected.to be_falsy
@@ -67,7 +94,7 @@ RSpec.describe Tooling::Danger::Changelog do
end
context 'when MR contains a label that require no changelog' do
- let(:changes_by_category) { { category_with_changelog => nil } }
+ let(:changes) { changes_class.new([change_class.new('foo', :modified, category_with_changelog)]) }
let(:mr_labels) { [label_with_changelog, label_without_changelog] }
it 'is falsey' do
@@ -76,29 +103,28 @@ RSpec.describe Tooling::Danger::Changelog do
end
context 'when MR contains a category that require changelog and a category that require no changelog' do
- let(:changes_by_category) { { category_with_changelog => nil, category_without_changelog => nil } }
- let(:mr_labels) { [] }
+ let(:changes) { changes_class.new([change_class.new('foo', :modified, category_with_changelog), change_class.new('foo', :modified, category_without_changelog)]) }
- it 'is truthy' do
- is_expected.to be_truthy
+ context 'with no labels' do
+ it 'is truthy' do
+ is_expected.to be_truthy
+ end
end
- end
- context 'when MR contains a category that require changelog and a category that require no changelog with changelog label' do
- let(:changes_by_category) { { category_with_changelog => nil, category_without_changelog => nil } }
- let(:mr_labels) { ['feature'] }
+ context 'with changelog label' do
+ let(:mr_labels) { ['feature'] }
- it 'is truthy' do
- is_expected.to be_truthy
+ it 'is truthy' do
+ is_expected.to be_truthy
+ end
end
- end
- context 'when MR contains a category that require changelog and a category that require no changelog with no changelog label' do
- let(:changes_by_category) { { category_with_changelog => nil, category_without_changelog => nil } }
- let(:mr_labels) { ['tooling'] }
+ context 'with no changelog label' do
+ let(:mr_labels) { ['tooling'] }
- it 'is truthy' do
- is_expected.to be_falsey
+ it 'is truthy' do
+ is_expected.to be_falsey
+ end
end
end
end
@@ -107,50 +133,35 @@ RSpec.describe Tooling::Danger::Changelog do
subject { changelog.found }
context 'added files contain a changelog' do
- [
- 'changelogs/unreleased/entry.yml',
- 'ee/changelogs/unreleased/entry.yml'
- ].each do |file_path|
- let(:added_files) { [file_path] }
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :changelog)]) }
- it { is_expected.to be_truthy }
- end
+ it { is_expected.to be_truthy }
end
context 'added files do not contain a changelog' do
- [
- 'app/models/model.rb',
- 'app/assets/javascripts/file.js'
- ].each do |file_path|
- let(:added_files) { [file_path] }
- it { is_expected.to eq(nil) }
- end
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :backend)]) }
+
+ it { is_expected.to eq(nil) }
end
end
describe '#ee_changelog?' do
subject { changelog.ee_changelog? }
- before do
- allow(changelog).to receive(:found).and_return(file_path)
- end
-
context 'is ee changelog' do
- let(:file_path) { 'ee/changelogs/unreleased/entry.yml' }
+ let(:changes) { changes_class.new([change_class.new('ee/changelogs/unreleased/entry.yml', :added, :changelog)]) }
it { is_expected.to be_truthy }
end
context 'is not ee changelog' do
- let(:file_path) { 'changelogs/unreleased/entry.yml' }
+ let(:changes) { changes_class.new([change_class.new('changelogs/unreleased/entry.yml', :added, :changelog)]) }
it { is_expected.to be_falsy }
end
end
describe '#modified_text' do
- let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } }
-
subject { changelog.modified_text }
context "when title is not changed from sanitization", :aggregate_failures do
@@ -174,35 +185,42 @@ RSpec.describe Tooling::Danger::Changelog do
end
end
- describe '#required_text' do
- let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } }
-
- subject { changelog.required_text }
+ describe '#required_texts' do
+ let(:sanitize_mr_title) { 'Fake Title' }
- context "when title is not changed from sanitization", :aggregate_failures do
- let(:sanitize_mr_title) { 'Fake Title' }
+ subject { changelog.required_texts }
+ shared_examples 'changelog required text' do |key|
specify do
- expect(subject).to include('CHANGELOG missing')
- expect(subject).to include('bin/changelog -m 1234 "Fake Title"')
- expect(subject).not_to include('--ee')
+ expect(subject).to have_key(key)
+ expect(subject[key]).to include('CHANGELOG missing')
+ expect(subject[key]).to include('bin/changelog -m 1234 "Fake Title"')
+ expect(subject[key]).not_to include('--ee')
end
end
- context "when title needs sanitization", :aggregate_failures do
- let(:sanitize_mr_title) { 'DRAFT: Fake Title' }
+ context 'with a new migration file' do
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :migration)]) }
- specify do
- expect(subject).to include('CHANGELOG missing')
- expect(subject).to include('bin/changelog -m 1234 "Fake Title"')
- expect(subject).not_to include('--ee')
+ context "when title is not changed from sanitization", :aggregate_failures do
+ it_behaves_like 'changelog required text', :db_changes
+ end
+
+ context "when title needs sanitization", :aggregate_failures do
+ let(:sanitize_mr_title) { 'DRAFT: Fake Title' }
+
+ it_behaves_like 'changelog required text', :db_changes
end
end
+
+ context 'with a removed feature flag file' do
+ let(:changes) { changes_class.new([change_class.new('foo', :deleted, :feature_flag)]) }
+
+ it_behaves_like 'changelog required text', :feature_flag_removed
+ end
end
describe '#optional_text' do
- let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } }
-
subject { changelog.optional_text }
context "when title is not changed from sanitization", :aggregate_failures do
diff --git a/spec/tooling/danger/helper_spec.rb b/spec/tooling/danger/helper_spec.rb
index c338d138352..9783705dca0 100644
--- a/spec/tooling/danger/helper_spec.rb
+++ b/spec/tooling/danger/helper_spec.rb
@@ -10,13 +10,27 @@ RSpec.describe Tooling::Danger::Helper do
using RSpec::Parameterized::TableSyntax
include DangerSpecHelper
- let(:fake_git) { double('fake-git') }
-
let(:mr_author) { nil }
let(:fake_gitlab) { double('fake-gitlab', mr_author: mr_author) }
let(:fake_danger) { new_fake_danger.include(described_class) }
+ let(:added_files) { %w[added1] }
+ let(:modified_files) { %w[modified1] }
+ let(:deleted_files) { %w[deleted1] }
+ let(:renamed_before_file) { 'renamed_before' }
+ let(:renamed_after_file) { 'renamed_after' }
+ let(:renamed_files) { [{ before: renamed_before_file, after: renamed_after_file }] }
+
+ let(:fake_git) { double('fake-git') }
+
+ before do
+ allow(fake_git).to receive(:added_files) { added_files }
+ allow(fake_git).to receive(:modified_files) { modified_files }
+ allow(fake_git).to receive(:deleted_files) { deleted_files }
+ allow(fake_git).to receive(:renamed_files).at_least(:twice) { renamed_files }
+ end
+
subject(:helper) { fake_danger.new(git: fake_git, gitlab: fake_gitlab) }
describe '#gitlab_helper' do
@@ -191,15 +205,16 @@ RSpec.describe Tooling::Danger::Helper do
end
describe '#changes_by_category' do
- it 'categorizes changed files' do
- expect(fake_git).to receive(:added_files) { %w[foo foo.md foo.rb foo.js db/migrate/foo lib/gitlab/database/foo.rb qa/foo ee/changelogs/foo.yml] }
- allow(fake_git).to receive(:modified_files) { [] }
- allow(fake_git).to receive(:renamed_files) { [] }
+ let(:added_files) { %w[foo foo.md foo.rb foo.js] }
+ let(:modified_files) { %w[db/migrate/foo lib/gitlab/database/foo.rb] }
+ let(:renamed_files) { [{ before: '', after: 'qa/foo' }, { before: '', after: 'ee/changelogs/foo.yml' }] }
+ it 'categorizes changed files' do
expect(helper.changes_by_category).to eq(
backend: %w[foo.rb],
database: %w[db/migrate/foo lib/gitlab/database/foo.rb],
frontend: %w[foo.js],
+ migration: %w[db/migrate/foo],
none: %w[ee/changelogs/foo.yml foo.md],
qa: %w[qa/foo],
unknown: %w[foo]
@@ -207,6 +222,62 @@ RSpec.describe Tooling::Danger::Helper do
end
end
+ describe 'Tooling::Danger::Helper::Changes', :aggregate_failures do
+ let(:added_files) { %w[db/migrate/foo ee/changelogs/unreleased/foo.yml] }
+
+ describe '#has_category?' do
+ it 'returns true when changes include given category, false otherwise' do
+ changes = helper.changes
+
+ expect(changes.has_category?(:migration)).to eq(true)
+ expect(changes.has_category?(:changelog)).to eq(true)
+ expect(changes.has_category?(:backend)).to eq(false)
+ end
+ end
+
+ describe '#by_category' do
+ it 'returns an array of Change objects' do
+ expect(helper.changes.by_category(:migration)).to all(be_an(described_class::Change))
+ end
+
+ it 'returns an array of Change objects with the given category' do
+ changes = helper.changes
+
+ expect(changes.by_category(:migration).files).to eq(['db/migrate/foo'])
+ expect(changes.by_category(:changelog).files).to eq(['ee/changelogs/unreleased/foo.yml'])
+ expect(changes.by_category(:backend)).to be_empty
+ end
+ end
+
+ describe '#categories' do
+ it 'returns an array of category symbols' do
+ expect(helper.changes.categories).to contain_exactly(:database, :migration, :changelog, :unknown)
+ end
+ end
+
+ describe '#files' do
+ it 'returns an array of files' do
+ expect(helper.changes.files).to include(*added_files)
+ end
+ end
+ end
+
+ describe '#changes' do
+ it 'returns an array of Change objects' do
+ expect(helper.changes).to all(be_an(described_class::Change))
+ end
+
+ it 'groups changes by change type' do
+ changes = helper.changes
+
+ expect(changes.added.files).to eq(added_files)
+ expect(changes.modified.files).to eq(modified_files)
+ expect(changes.deleted.files).to eq(deleted_files)
+ expect(changes.renamed_before.files).to eq([renamed_before_file])
+ expect(changes.renamed_after.files).to eq([renamed_after_file])
+ end
+ end
+
describe '#categories_for_file' do
before do
allow(fake_git).to receive(:diff_for_file).with('usage_data.rb') { double(:diff, patch: "+ count(User.active)") }
@@ -304,12 +375,10 @@ RSpec.describe Tooling::Danger::Helper do
'db/schema.rb' | [:database]
'db/structure.sql' | [:database]
- 'db/migrate/foo' | [:database]
- 'db/post_migrate/foo' | [:database]
- 'ee/db/migrate/foo' | [:database]
- 'ee/db/post_migrate/foo' | [:database]
- 'ee/db/geo/migrate/foo' | [:database]
- 'ee/db/geo/post_migrate/foo' | [:database]
+ 'db/migrate/foo' | [:database, :migration]
+ 'db/post_migrate/foo' | [:database, :migration]
+ 'ee/db/geo/migrate/foo' | [:database, :migration]
+ 'ee/db/geo/post_migrate/foo' | [:database, :migration]
'app/models/project_authorization.rb' | [:database]
'app/services/users/refresh_authorized_projects_service.rb' | [:database]
'lib/gitlab/background_migration.rb' | [:database]
@@ -325,8 +394,6 @@ RSpec.describe Tooling::Danger::Helper do
'db/fixtures/foo.rb' | [:backend]
'ee/db/fixtures/foo.rb' | [:backend]
- 'doc/api/graphql/reference/gitlab_schema.graphql' | [:backend]
- 'doc/api/graphql/reference/gitlab_schema.json' | [:backend]
'qa/foo' | [:qa]
'ee/qa/foo' | [:qa]
@@ -402,6 +469,22 @@ RSpec.describe Tooling::Danger::Helper do
end
end
+ describe '#mr_iid' do
+ it 'returns "" when `gitlab_helper` is unavailable' do
+ expect(helper).to receive(:gitlab_helper).and_return(nil)
+
+ expect(helper.mr_iid).to eq('')
+ end
+
+ it 'returns the MR IID when `gitlab_helper` is available' do
+ mr_iid = '1234'
+ expect(fake_gitlab).to receive(:mr_json)
+ .and_return('iid' => mr_iid)
+
+ expect(helper.mr_iid).to eq(mr_iid)
+ end
+ end
+
describe '#mr_title' do
it 'returns "" when `gitlab_helper` is unavailable' do
expect(helper).to receive(:gitlab_helper).and_return(nil)
@@ -434,6 +517,22 @@ RSpec.describe Tooling::Danger::Helper do
end
end
+ describe '#mr_labels' do
+ it 'returns "" when `gitlab_helper` is unavailable' do
+ expect(helper).to receive(:gitlab_helper).and_return(nil)
+
+ expect(helper.mr_labels).to eq([])
+ end
+
+ it 'returns the MR labels when `gitlab_helper` is available' do
+ mr_labels = %w[foo bar baz]
+ expect(fake_gitlab).to receive(:mr_labels)
+ .and_return(mr_labels)
+
+ expect(helper.mr_labels).to eq(mr_labels)
+ end
+ end
+
describe '#mr_target_branch' do
it 'returns "" when `gitlab_helper` is unavailable' do
expect(helper).to receive(:gitlab_helper).and_return(nil)
diff --git a/spec/tooling/rspec_flaky/config_spec.rb b/spec/tooling/rspec_flaky/config_spec.rb
new file mode 100644
index 00000000000..12b5ed74cb2
--- /dev/null
+++ b/spec/tooling/rspec_flaky/config_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'rspec-parameterized'
+require_relative '../../support/helpers/stub_env'
+
+require_relative '../../../tooling/rspec_flaky/config'
+
+RSpec.describe RspecFlaky::Config, :aggregate_failures do
+ include StubENV
+
+ before do
+ # Stub these env variables otherwise specs don't behave the same on the CI
+ stub_env('FLAKY_RSPEC_GENERATE_REPORT', nil)
+ stub_env('SUITE_FLAKY_RSPEC_REPORT_PATH', nil)
+ stub_env('FLAKY_RSPEC_REPORT_PATH', nil)
+ stub_env('NEW_FLAKY_RSPEC_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
+ end
+ end
+
+ describe '.generate_report?' do
+ context "when ENV['FLAKY_RSPEC_GENERATE_REPORT'] is not set" do
+ it 'returns false' do
+ expect(described_class).not_to be_generate_report
+ end
+ end
+
+ context "when ENV['FLAKY_RSPEC_GENERATE_REPORT'] is set" do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:env_value, :result) do
+ '1' | true
+ 'true' | true
+ 'foo' | false
+ '0' | false
+ 'false' | false
+ end
+
+ with_them do
+ before do
+ stub_env('FLAKY_RSPEC_GENERATE_REPORT', env_value)
+ end
+
+ it 'returns false' do
+ expect(described_class.generate_report?).to be(result)
+ end
+ end
+ end
+ end
+
+ describe '.suite_flaky_examples_report_path' do
+ context "when ENV['SUITE_FLAKY_RSPEC_REPORT_PATH'] is not set" do
+ it 'returns the default path' do
+ expect(described_class.suite_flaky_examples_report_path).to eq('rspec_flaky/suite-report.json')
+ end
+ end
+
+ context "when ENV['SUITE_FLAKY_RSPEC_REPORT_PATH'] is set" do
+ before do
+ stub_env('SUITE_FLAKY_RSPEC_REPORT_PATH', 'foo/suite-report.json')
+ end
+
+ it 'returns the value of the env variable' do
+ expect(described_class.suite_flaky_examples_report_path).to eq('foo/suite-report.json')
+ end
+ end
+ end
+
+ describe '.flaky_examples_report_path' do
+ context "when ENV['FLAKY_RSPEC_REPORT_PATH'] is not set" do
+ it 'returns the default path' do
+ expect(described_class.flaky_examples_report_path).to eq('rspec_flaky/report.json')
+ end
+ end
+
+ context "when ENV['FLAKY_RSPEC_REPORT_PATH'] is set" do
+ before do
+ stub_env('FLAKY_RSPEC_REPORT_PATH', 'foo/report.json')
+ end
+
+ it 'returns the value of the env variable' do
+ expect(described_class.flaky_examples_report_path).to eq('foo/report.json')
+ end
+ end
+ end
+
+ describe '.new_flaky_examples_report_path' do
+ context "when ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] is not set" do
+ it 'returns the default path' do
+ expect(described_class.new_flaky_examples_report_path).to eq('rspec_flaky/new-report.json')
+ end
+ end
+
+ context "when ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] is set" do
+ before do
+ stub_env('NEW_FLAKY_RSPEC_REPORT_PATH', 'foo/new-report.json')
+ end
+
+ it 'returns the value of the env variable' do
+ expect(described_class.new_flaky_examples_report_path).to eq('foo/new-report.json')
+ end
+ end
+ end
+end
diff --git a/spec/tooling/rspec_flaky/example_spec.rb b/spec/tooling/rspec_flaky/example_spec.rb
new file mode 100644
index 00000000000..8ff280fd855
--- /dev/null
+++ b/spec/tooling/rspec_flaky/example_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require_relative '../../../tooling/rspec_flaky/example'
+
+RSpec.describe RspecFlaky::Example do
+ let(:example_attrs) do
+ {
+ id: 'spec/foo/bar_spec.rb:2',
+ metadata: {
+ file_path: 'spec/foo/bar_spec.rb',
+ line_number: 2,
+ full_description: 'hello world'
+ },
+ execution_result: double(status: 'passed', exception: 'BOOM!'),
+ attempts: 1
+ }
+ end
+
+ let(:rspec_example) { double(example_attrs) }
+
+ describe '#initialize' do
+ shared_examples 'a valid Example instance' do
+ it 'returns valid attributes' do
+ example = described_class.new(args)
+
+ expect(example.example_id).to eq(example_attrs[:id])
+ end
+ end
+
+ context 'when given an Rspec::Core::Example that responds to #example' do
+ let(:args) { double(example: rspec_example) }
+
+ it_behaves_like 'a valid Example instance'
+ end
+
+ context 'when given an Rspec::Core::Example that does not respond to #example' do
+ let(:args) { rspec_example }
+
+ it_behaves_like 'a valid Example instance'
+ end
+ end
+
+ subject { described_class.new(rspec_example) }
+
+ describe '#uid' do
+ it 'returns a hash of the full description' do
+ expect(subject.uid).to eq(Digest::MD5.hexdigest("#{subject.description}-#{subject.file}"))
+ end
+ end
+
+ describe '#example_id' do
+ it 'returns the ID of the RSpec::Core::Example' do
+ expect(subject.example_id).to eq(rspec_example.id)
+ end
+ end
+
+ describe '#attempts' do
+ it 'returns the attempts of the RSpec::Core::Example' do
+ expect(subject.attempts).to eq(rspec_example.attempts)
+ end
+ end
+
+ describe '#file' do
+ it 'returns the metadata[:file_path] of the RSpec::Core::Example' do
+ expect(subject.file).to eq(rspec_example.metadata[:file_path])
+ end
+ end
+
+ describe '#line' do
+ it 'returns the metadata[:line_number] of the RSpec::Core::Example' do
+ expect(subject.line).to eq(rspec_example.metadata[:line_number])
+ end
+ end
+
+ describe '#description' do
+ it 'returns the metadata[:full_description] of the RSpec::Core::Example' do
+ expect(subject.description).to eq(rspec_example.metadata[:full_description])
+ end
+ end
+
+ describe '#status' do
+ it 'returns the execution_result.status of the RSpec::Core::Example' do
+ expect(subject.status).to eq(rspec_example.execution_result.status)
+ end
+ end
+
+ describe '#exception' do
+ it 'returns the execution_result.exception of the RSpec::Core::Example' do
+ expect(subject.exception).to eq(rspec_example.execution_result.exception)
+ end
+ end
+end
diff --git a/spec/tooling/rspec_flaky/flaky_example_spec.rb b/spec/tooling/rspec_flaky/flaky_example_spec.rb
new file mode 100644
index 00000000000..ab652662c0b
--- /dev/null
+++ b/spec/tooling/rspec_flaky/flaky_example_spec.rb
@@ -0,0 +1,185 @@
+# frozen_string_literal: true
+
+require 'active_support/testing/time_helpers'
+require_relative '../../support/helpers/stub_env'
+
+require_relative '../../../tooling/rspec_flaky/flaky_example'
+
+RSpec.describe RspecFlaky::FlakyExample, :aggregate_failures do
+ include ActiveSupport::Testing::TimeHelpers
+ include StubENV
+
+ let(:flaky_example_attrs) do
+ {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ file: 'spec/foo/bar_spec.rb',
+ line: 2,
+ description: 'hello world',
+ first_flaky_at: 1234,
+ last_flaky_at: 2345,
+ last_flaky_job: 'https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/12',
+ last_attempts_count: 2,
+ flaky_reports: 1
+ }
+ end
+
+ let(:example_attrs) do
+ {
+ uid: 'abc123',
+ example_id: flaky_example_attrs[:example_id],
+ file: flaky_example_attrs[:file],
+ line: flaky_example_attrs[:line],
+ description: flaky_example_attrs[:description],
+ status: 'passed',
+ exception: 'BOOM!',
+ attempts: flaky_example_attrs[:last_attempts_count]
+ }
+ end
+
+ let(:example) { OpenStruct.new(example_attrs) }
+
+ before do
+ # Stub these env variables otherwise specs don't behave the same on the CI
+ stub_env('CI_PROJECT_URL', nil)
+ stub_env('CI_JOB_ID', nil)
+ end
+
+ describe '#initialize' do
+ shared_examples 'a valid FlakyExample instance' do
+ let(:flaky_example) { described_class.new(args) }
+
+ it 'returns valid attributes' do
+ expect(flaky_example.uid).to eq(flaky_example_attrs[:uid])
+ expect(flaky_example.file).to eq(flaky_example_attrs[:file])
+ expect(flaky_example.line).to eq(flaky_example_attrs[:line])
+ expect(flaky_example.description).to eq(flaky_example_attrs[:description])
+ expect(flaky_example.first_flaky_at).to eq(expected_first_flaky_at)
+ expect(flaky_example.last_flaky_at).to eq(expected_last_flaky_at)
+ expect(flaky_example.last_attempts_count).to eq(flaky_example_attrs[:last_attempts_count])
+ expect(flaky_example.flaky_reports).to eq(expected_flaky_reports)
+ end
+ end
+
+ context 'when given an Rspec::Example' do
+ it_behaves_like 'a valid FlakyExample instance' do
+ let(:args) { example }
+ let(:expected_first_flaky_at) { nil }
+ let(:expected_last_flaky_at) { nil }
+ let(:expected_flaky_reports) { 0 }
+ end
+ end
+
+ context 'when given a hash' do
+ it_behaves_like 'a valid FlakyExample instance' do
+ let(:args) { flaky_example_attrs }
+ let(:expected_flaky_reports) { flaky_example_attrs[:flaky_reports] }
+ let(:expected_first_flaky_at) { flaky_example_attrs[:first_flaky_at] }
+ let(:expected_last_flaky_at) { flaky_example_attrs[:last_flaky_at] }
+ end
+ end
+ end
+
+ describe '#update_flakiness!' do
+ shared_examples 'an up-to-date FlakyExample instance' do
+ let(:flaky_example) { described_class.new(args) }
+
+ it 'sets the first_flaky_at if none exists' do
+ args[:first_flaky_at] = nil
+
+ freeze_time do
+ flaky_example.update_flakiness!
+
+ expect(flaky_example.first_flaky_at).to eq(Time.now)
+ end
+ end
+
+ it 'maintains the first_flaky_at if exists' do
+ flaky_example.update_flakiness!
+ expected_first_flaky_at = flaky_example.first_flaky_at
+
+ travel_to(Time.now + 42) do
+ flaky_example.update_flakiness!
+ expect(flaky_example.first_flaky_at).to eq(expected_first_flaky_at)
+ end
+ end
+
+ it 'updates the last_flaky_at' do
+ travel_to(Time.now + 42) do
+ the_future = Time.now
+ flaky_example.update_flakiness!
+
+ expect(flaky_example.last_flaky_at).to eq(the_future)
+ end
+ end
+
+ it 'updates the flaky_reports' do
+ expected_flaky_reports = flaky_example.first_flaky_at ? flaky_example.flaky_reports + 1 : 1
+
+ expect { flaky_example.update_flakiness! }.to change { flaky_example.flaky_reports }.by(1)
+ expect(flaky_example.flaky_reports).to eq(expected_flaky_reports)
+ end
+
+ context 'when passed a :last_attempts_count' do
+ it 'updates the last_attempts_count' do
+ flaky_example.update_flakiness!(last_attempts_count: 42)
+
+ expect(flaky_example.last_attempts_count).to eq(42)
+ end
+ end
+
+ context 'when run on the CI' do
+ before do
+ stub_env('CI_PROJECT_URL', 'https://gitlab.com/gitlab-org/gitlab-foss')
+ stub_env('CI_JOB_ID', 42)
+ end
+
+ it 'updates the last_flaky_job' do
+ flaky_example.update_flakiness!
+
+ expect(flaky_example.last_flaky_job).to eq('https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/42')
+ end
+ end
+ end
+
+ context 'when given an Rspec::Example' do
+ it_behaves_like 'an up-to-date FlakyExample instance' do
+ let(:args) { example }
+ end
+ end
+
+ context 'when given a hash' do
+ it_behaves_like 'an up-to-date FlakyExample instance' do
+ let(:args) { flaky_example_attrs }
+ end
+ end
+ end
+
+ describe '#to_h' do
+ shared_examples 'a valid FlakyExample hash' do
+ let(:additional_attrs) { {} }
+
+ it 'returns a valid hash' do
+ flaky_example = described_class.new(args)
+ final_hash = flaky_example_attrs.merge(additional_attrs)
+
+ expect(flaky_example.to_h).to eq(final_hash)
+ end
+ end
+
+ context 'when given an Rspec::Example' do
+ let(:args) { example }
+
+ it_behaves_like 'a valid FlakyExample hash' do
+ let(:additional_attrs) do
+ { first_flaky_at: nil, last_flaky_at: nil, last_flaky_job: nil, flaky_reports: 0 }
+ end
+ end
+ end
+
+ context 'when given a hash' do
+ let(:args) { flaky_example_attrs }
+
+ it_behaves_like 'a valid FlakyExample hash'
+ end
+ end
+end
diff --git a/spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb b/spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb
new file mode 100644
index 00000000000..823459e31b4
--- /dev/null
+++ b/spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require_relative '../../../tooling/rspec_flaky/flaky_examples_collection'
+
+RSpec.describe RspecFlaky::FlakyExamplesCollection, :aggregate_failures do
+ let(:collection_hash) do
+ {
+ a: { example_id: 'spec/foo/bar_spec.rb:2' },
+ b: { example_id: 'spec/foo/baz_spec.rb:3' }
+ }
+ end
+
+ let(:collection_report) do
+ {
+ a: {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ first_flaky_at: nil,
+ last_flaky_at: nil,
+ last_flaky_job: nil
+ },
+ b: {
+ example_id: 'spec/foo/baz_spec.rb:3',
+ first_flaky_at: nil,
+ last_flaky_at: nil,
+ last_flaky_job: nil
+ }
+ }
+ end
+
+ describe '#initialize' do
+ it 'accepts no argument' do
+ expect { described_class.new }.not_to raise_error
+ end
+
+ it 'accepts a hash' do
+ expect { described_class.new(collection_hash) }.not_to raise_error
+ end
+
+ it 'does not accept anything else' do
+ expect { described_class.new([1, 2, 3]) }.to raise_error(ArgumentError, "`collection` must be a Hash, Array given!")
+ end
+ end
+
+ describe '#to_h' do
+ it 'calls #to_h on the values' do
+ collection = described_class.new(collection_hash)
+
+ expect(collection.to_h).to eq(collection_report)
+ end
+ end
+
+ describe '#-' do
+ it 'returns only examples that are not present in the given collection' do
+ collection1 = described_class.new(collection_hash)
+ collection2 = described_class.new(
+ a: { example_id: 'spec/foo/bar_spec.rb:2' },
+ c: { example_id: 'spec/bar/baz_spec.rb:4' })
+
+ expect((collection2 - collection1).to_h).to eq(
+ c: {
+ example_id: 'spec/bar/baz_spec.rb:4',
+ first_flaky_at: nil,
+ last_flaky_at: nil,
+ last_flaky_job: nil
+ })
+ end
+
+ it 'fails if the given collection does not respond to `#key?`' do
+ collection = described_class.new(collection_hash)
+
+ expect { collection - [1, 2, 3] }.to raise_error(ArgumentError, "`other` must respond to `#key?`, Array does not!")
+ end
+ end
+end
diff --git a/spec/tooling/rspec_flaky/listener_spec.rb b/spec/tooling/rspec_flaky/listener_spec.rb
new file mode 100644
index 00000000000..429724a20cf
--- /dev/null
+++ b/spec/tooling/rspec_flaky/listener_spec.rb
@@ -0,0 +1,227 @@
+# frozen_string_literal: true
+
+require 'active_support/testing/time_helpers'
+require_relative '../../support/helpers/stub_env'
+
+require_relative '../../../tooling/rspec_flaky/listener'
+
+RSpec.describe RspecFlaky::Listener, :aggregate_failures do
+ include ActiveSupport::Testing::TimeHelpers
+ include StubENV
+
+ let(:already_flaky_example_uid) { '6e869794f4cfd2badd93eb68719371d1' }
+ let(:suite_flaky_example_report) do
+ {
+ "#{already_flaky_example_uid}": {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ file: 'spec/foo/bar_spec.rb',
+ line: 2,
+ description: 'hello world',
+ first_flaky_at: 1234,
+ last_flaky_at: 4321,
+ last_attempts_count: 3,
+ flaky_reports: 1,
+ last_flaky_job: nil
+ }
+ }
+ end
+
+ let(:already_flaky_example_attrs) do
+ {
+ id: 'spec/foo/bar_spec.rb:2',
+ metadata: {
+ file_path: 'spec/foo/bar_spec.rb',
+ line_number: 2,
+ full_description: 'hello world'
+ },
+ execution_result: double(status: 'passed', exception: nil)
+ }
+ end
+
+ let(:already_flaky_example) { RspecFlaky::FlakyExample.new(suite_flaky_example_report[already_flaky_example_uid]) }
+ let(:new_example_attrs) do
+ {
+ id: 'spec/foo/baz_spec.rb:3',
+ metadata: {
+ file_path: 'spec/foo/baz_spec.rb',
+ line_number: 3,
+ full_description: 'hello GitLab'
+ },
+ execution_result: double(status: 'passed', exception: nil)
+ }
+ end
+
+ before do
+ # Stub these env variables otherwise specs don't behave the same on the CI
+ stub_env('CI_PROJECT_URL', nil)
+ stub_env('CI_JOB_ID', nil)
+ stub_env('SUITE_FLAKY_RSPEC_REPORT_PATH', nil)
+ end
+
+ describe '#initialize' do
+ shared_examples 'a valid Listener instance' do
+ let(:expected_suite_flaky_examples) { {} }
+
+ it 'returns a valid Listener instance' do
+ listener = described_class.new
+
+ expect(listener.suite_flaky_examples.to_h).to eq(expected_suite_flaky_examples)
+ expect(listener.flaky_examples).to eq({})
+ end
+ end
+
+ context 'when no report file exists' do
+ it_behaves_like 'a valid Listener instance'
+ end
+
+ context 'when SUITE_FLAKY_RSPEC_REPORT_PATH is set' do
+ let(:report_file_path) { 'foo/report.json' }
+
+ before do
+ stub_env('SUITE_FLAKY_RSPEC_REPORT_PATH', report_file_path)
+ end
+
+ context 'and report file exists' do
+ before do
+ expect(File).to receive(:exist?).with(report_file_path).and_return(true)
+ end
+
+ it 'delegates the load to RspecFlaky::Report' do
+ report = RspecFlaky::Report.new(RspecFlaky::FlakyExamplesCollection.new(suite_flaky_example_report))
+
+ expect(RspecFlaky::Report).to receive(:load).with(report_file_path).and_return(report)
+ expect(described_class.new.suite_flaky_examples.to_h).to eq(report.flaky_examples.to_h)
+ end
+ end
+
+ context 'and report file does not exist' do
+ before do
+ expect(File).to receive(:exist?).with(report_file_path).and_return(false)
+ end
+
+ it 'return an empty hash' do
+ expect(RspecFlaky::Report).not_to receive(:load)
+ expect(described_class.new.suite_flaky_examples.to_h).to eq({})
+ end
+ end
+ end
+ end
+
+ describe '#example_passed' do
+ let(:rspec_example) { double(new_example_attrs) }
+ let(:notification) { double(example: rspec_example) }
+ let(:listener) { described_class.new(suite_flaky_example_report.to_json) }
+
+ shared_examples 'a non-flaky example' do
+ it 'does not change the flaky examples hash' do
+ expect { listener.example_passed(notification) }
+ .not_to change { listener.flaky_examples }
+ end
+ end
+
+ shared_examples 'an existing flaky example' do
+ let(:expected_flaky_example) do
+ {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ file: 'spec/foo/bar_spec.rb',
+ line: 2,
+ description: 'hello world',
+ first_flaky_at: 1234,
+ last_attempts_count: 2,
+ flaky_reports: 2,
+ last_flaky_job: nil
+ }
+ end
+
+ it 'changes the flaky examples hash' do
+ new_example = RspecFlaky::Example.new(rspec_example)
+
+ travel_to(Time.now + 42) do
+ the_future = Time.now
+ expect { listener.example_passed(notification) }
+ .to change { listener.flaky_examples[new_example.uid].to_h }
+ expect(listener.flaky_examples[new_example.uid].to_h)
+ .to eq(expected_flaky_example.merge(last_flaky_at: the_future))
+ end
+ end
+ end
+
+ shared_examples 'a new flaky example' do
+ let(:expected_flaky_example) do
+ {
+ example_id: 'spec/foo/baz_spec.rb:3',
+ file: 'spec/foo/baz_spec.rb',
+ line: 3,
+ description: 'hello GitLab',
+ last_attempts_count: 2,
+ flaky_reports: 1,
+ last_flaky_job: nil
+ }
+ end
+
+ it 'changes the all flaky examples hash' do
+ new_example = RspecFlaky::Example.new(rspec_example)
+
+ travel_to(Time.now + 42) do
+ the_future = Time.now
+ expect { listener.example_passed(notification) }
+ .to change { listener.flaky_examples[new_example.uid].to_h }
+ expect(listener.flaky_examples[new_example.uid].to_h)
+ .to eq(expected_flaky_example.merge(first_flaky_at: the_future, last_flaky_at: the_future))
+ end
+ end
+ end
+
+ describe 'when the RSpec example does not respond to attempts' do
+ it_behaves_like 'a non-flaky example'
+ end
+
+ describe 'when the RSpec example has 1 attempt' do
+ let(:rspec_example) { double(new_example_attrs.merge(attempts: 1)) }
+
+ it_behaves_like 'a non-flaky example'
+ end
+
+ describe 'when the RSpec example has 2 attempts' do
+ let(:rspec_example) { double(new_example_attrs.merge(attempts: 2)) }
+
+ it_behaves_like 'a new flaky example'
+
+ context 'with an existing flaky example' do
+ let(:rspec_example) { double(already_flaky_example_attrs.merge(attempts: 2)) }
+
+ it_behaves_like 'an existing flaky example'
+ end
+ end
+ end
+
+ describe '#dump_summary' do
+ let(:listener) { described_class.new(suite_flaky_example_report.to_json) }
+ let(:new_flaky_rspec_example) { double(new_example_attrs.merge(attempts: 2)) }
+ let(:already_flaky_rspec_example) { double(already_flaky_example_attrs.merge(attempts: 2)) }
+ let(:notification_new_flaky_rspec_example) { double(example: new_flaky_rspec_example) }
+ let(:notification_already_flaky_rspec_example) { double(example: already_flaky_rspec_example) }
+
+ before do
+ allow(Kernel).to receive(:warn)
+ end
+
+ context 'when a report file path is set by FLAKY_RSPEC_REPORT_PATH' do
+ it 'delegates the writes to RspecFlaky::Report' do
+ listener.example_passed(notification_new_flaky_rspec_example)
+ listener.example_passed(notification_already_flaky_rspec_example)
+
+ report1 = double
+ report2 = double
+
+ expect(RspecFlaky::Report).to receive(:new).with(listener.flaky_examples).and_return(report1)
+ expect(report1).to receive(:write).with(RspecFlaky::Config.flaky_examples_report_path)
+
+ expect(RspecFlaky::Report).to receive(:new).with(listener.flaky_examples - listener.suite_flaky_examples).and_return(report2)
+ expect(report2).to receive(:write).with(RspecFlaky::Config.new_flaky_examples_report_path)
+
+ listener.dump_summary(nil)
+ end
+ end
+ end
+end
diff --git a/spec/tooling/rspec_flaky/report_spec.rb b/spec/tooling/rspec_flaky/report_spec.rb
new file mode 100644
index 00000000000..6c364cd5cd3
--- /dev/null
+++ b/spec/tooling/rspec_flaky/report_spec.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+require 'tempfile'
+
+require_relative '../../../tooling/rspec_flaky/report'
+
+RSpec.describe RspecFlaky::Report, :aggregate_failures do
+ let(:thirty_one_days) { 3600 * 24 * 31 }
+ let(:collection_hash) do
+ {
+ a: { example_id: 'spec/foo/bar_spec.rb:2' },
+ b: { example_id: 'spec/foo/baz_spec.rb:3', first_flaky_at: (Time.now - thirty_one_days).to_s, last_flaky_at: (Time.now - thirty_one_days).to_s }
+ }
+ end
+
+ let(:suite_flaky_example_report) do
+ {
+ '6e869794f4cfd2badd93eb68719371d1': {
+ example_id: 'spec/foo/bar_spec.rb:2',
+ file: 'spec/foo/bar_spec.rb',
+ line: 2,
+ description: 'hello world',
+ first_flaky_at: 1234,
+ last_flaky_at: 4321,
+ last_attempts_count: 3,
+ flaky_reports: 1,
+ last_flaky_job: nil
+ }
+ }
+ end
+
+ let(:flaky_examples) { RspecFlaky::FlakyExamplesCollection.new(collection_hash) }
+ let(:report) { described_class.new(flaky_examples) }
+
+ before do
+ allow(Kernel).to receive(:warn)
+ end
+
+ describe '.load' do
+ let!(:report_file) do
+ Tempfile.new(%w[rspec_flaky_report .json]).tap do |f|
+ f.write(JSON.pretty_generate(suite_flaky_example_report)) # rubocop:disable Gitlab/Json
+ f.rewind
+ end
+ end
+
+ after do
+ report_file.close
+ report_file.unlink
+ end
+
+ it 'loads the report file' do
+ expect(described_class.load(report_file.path).flaky_examples.to_h).to eq(suite_flaky_example_report)
+ end
+ end
+
+ describe '.load_json' do
+ let(:report_json) do
+ JSON.pretty_generate(suite_flaky_example_report) # rubocop:disable Gitlab/Json
+ end
+
+ it 'loads the report file' do
+ expect(described_class.load_json(report_json).flaky_examples.to_h).to eq(suite_flaky_example_report)
+ end
+ end
+
+ describe '#initialize' do
+ it 'accepts a RspecFlaky::FlakyExamplesCollection' do
+ expect { report }.not_to raise_error
+ end
+
+ it 'does not accept anything else' do
+ expect { described_class.new([1, 2, 3]) }.to raise_error(ArgumentError, "`flaky_examples` must be a RspecFlaky::FlakyExamplesCollection, Array given!")
+ end
+ end
+
+ it 'delegates to #flaky_examples using SimpleDelegator' do
+ expect(report.__getobj__).to eq(flaky_examples)
+ end
+
+ describe '#write' do
+ let(:report_file_path) { File.join('tmp', 'rspec_flaky_report.json') }
+
+ before do
+ FileUtils.rm(report_file_path) if File.exist?(report_file_path)
+ end
+
+ after do
+ FileUtils.rm(report_file_path) if File.exist?(report_file_path)
+ end
+
+ context 'when RspecFlaky::Config.generate_report? is false' do
+ before do
+ allow(RspecFlaky::Config).to receive(:generate_report?).and_return(false)
+ end
+
+ it 'does not write any report file' do
+ report.write(report_file_path)
+
+ expect(File.exist?(report_file_path)).to be(false)
+ end
+ end
+
+ context 'when RspecFlaky::Config.generate_report? is true' do
+ before do
+ allow(RspecFlaky::Config).to receive(:generate_report?).and_return(true)
+ end
+
+ it 'delegates the writes to RspecFlaky::Report' do
+ report.write(report_file_path)
+
+ expect(File.exist?(report_file_path)).to be(true)
+ expect(File.read(report_file_path))
+ .to eq(JSON.pretty_generate(report.flaky_examples.to_h)) # rubocop:disable Gitlab/Json
+ end
+ end
+ end
+
+ describe '#prune_outdated' do
+ it 'returns a new collection without the examples older than 30 days by default' do
+ new_report = flaky_examples.to_h.dup.tap { |r| r.delete(:b) }
+ new_flaky_examples = report.prune_outdated
+
+ expect(new_flaky_examples).to be_a(described_class)
+ expect(new_flaky_examples.to_h).to eq(new_report)
+ expect(flaky_examples).to have_key(:b)
+ end
+
+ it 'accepts a given number of days' do
+ new_flaky_examples = report.prune_outdated(days: 32)
+
+ expect(new_flaky_examples.to_h).to eq(report.to_h)
+ end
+ end
+end