quarantine 1.0.7 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b012ecdd8fb1b5f4b8d014d294bdce9e02370d46afd79bf1f69577c4821f9dc
4
- data.tar.gz: 6e9ac0c7edfb4d38db0487db389623b7ad75bdf342f74e97e471a6073d81357e
3
+ metadata.gz: 47aa8e8df1db802259f0107a7a06c2d05162899b803ffd224da8a53246c1e105
4
+ data.tar.gz: 8cb6c6c9c5b79666c10feaf38e0077eab27b57d494dc08e0c385925de1c89583
5
5
  SHA512:
6
- metadata.gz: e63db8d4dfc55809f9099fde29350ad86d88d6fb5617bfff576263bbd9ebb155f76d0f38c6e5dc9c7b465c969a8b21714ebacde8c7f8534c207aada2337d632b
7
- data.tar.gz: 06ee957f72bcdf07da8b3b67d0bb5cbf62aa970c81dd991eb2a28c962d69c232b7f06a37062cf288b74531099e4aa7f2d0935fa00e296961d095c814ede34faf
6
+ metadata.gz: e3f9ba5314c425861f18b7451a7f28f8ced4af50479ad880f0be263a9104ba3c6dd85bcada8a0c89d194e9487d02702c9fe4930caceebf835413891fc31d6a37
7
+ data.tar.gz: 3177827c0ff075302fa8e62c0b3480728769a3cb6199cb8eb0e510237df9839d0efc10db0180d32016d1ced933d9d50f87336f5f2d6ff3f741622a5aed3364b2
data/CHANGELOG.md CHANGED
@@ -1,21 +1,20 @@
1
1
  ### 1.0.7
2
- Support `it_behaves_like` behavior
2
+ Support `it_behaves_like` behavior
3
3
 
4
4
  ### 1.0.6
5
- Update DynamoDB batch_write_item implementation to check for duplicates based on different keys before uploading
5
+ Update DynamoDB batch_write_item implementation to check for duplicates based on different keys before uploading
6
6
 
7
7
  ### 1.0.5
8
- Add aws_credentials argument during dynamodb initialization to override the AWS SDK credential chain
8
+ Add aws_credentials argument during dynamodb initialization to override the AWS SDK credential chain
9
9
 
10
10
  ### 1.0.4
11
- Enable upstream callers to mark an example as flaky through the example's metadata
11
+ Enable upstream callers to mark an example as flaky through the example's metadata
12
12
 
13
13
  ### 1.0.3
14
- Only require dynamodb instead of full aws-sdk
14
+ Only require dynamodb instead of full aws-sdk
15
15
 
16
16
  ### 1.0.2
17
- Relax Aws gem version constraint as aws-sdk v3 is 100% backwards
18
- compatible with v2
17
+ Relax Aws gem version constraint as aws-sdk v3 is 100% backwards compatible with v2
19
18
 
20
19
  ### 1.0.1
21
- Initial Release
20
+ Initial Release
data/README.md CHANGED
@@ -1,27 +1,18 @@
1
1
  # Quarantine
2
+
2
3
  [![Build Status](https://travis-ci.com/flexport/quarantine.svg?branch=master)](https://travis-ci.com/flexport/quarantine)
3
4
 
4
- Quarantine provides a run-time solution to diagnosing and disabling flaky tests and automates the workflow around test suite maintenance.
5
+ Quarantine automatically detects flaky tests (i.e. those which fail non-deterministically) and disables them until they're proven reliable.
5
6
 
6
- The quarantine gem supports testing frameworks:
7
+ Quarantine current supports the following testing frameworks. If you need an additional one, please file an issue or open a pull request.
7
8
  - [RSpec](http://rspec.info/)
8
9
 
9
- The quarantine gem supports CI pipelines:
10
- - [Buildkite](https://buildkite.com/docs/tutorials/getting-started)
11
-
12
- If you are interested in using quarantine but it does not support your CI or testing framework, feel free to reach out or create an issue and we can try to make it happen.
10
+ Quarantine should provide the necessary hooks for compatibility with any CI solution. If it's insufficient for yours, please file an issue or open a pull request.
13
11
 
14
- ## Purpose
15
- Flaky tests impact engineering velocity, reduce faith in test reliablity and give a false representation of code coverage. Managing flaky tests is a clunky process that involves constant build monitorization, difficult diagnosis and manual ticket creation. As a result, here at Flexport, we have created a Gem to automate the entire process to help improve the workflow and keep our massive test suites in pristine condition.
12
+ ## Getting started
16
13
 
17
- The workflow at Flexport involves:
14
+ Quarantine works in tandem with [RSpec::Retry](https://github.com/NoRedInk/rspec-retry). Add this to your `Gemfile` and run `bundle install`:
18
15
 
19
- ![ideal workflow](misc/flexport_workflow.png)
20
-
21
- ---
22
- ## Installation and Setup
23
-
24
- Add these lines to your application's Gemfile:
25
16
  ```rb
26
17
  group :test do
27
18
  gem 'quarantine'
@@ -29,123 +20,133 @@ group :test do
29
20
  end
30
21
  ```
31
22
 
32
- And then execute:
33
- ```sh
34
- bundle install
35
- ```
23
+ In your `spec_helper.rb`, set up Quarantine and RSpec::Retry. See [RSpec::Retry](https://github.com/NoRedInk/rspec-retry)'s documentation for details of its configuration.
36
24
 
37
- In your `spec_helper.rb` setup quarantine and rspec-retry gem. Click [rspec-retry](https://github.com/NoRedInk/rspec-retry) to get a more detailed explaination on rspec-retry configurations and how to setup.
38
25
  ```rb
39
26
  require 'quarantine'
40
- require 'rspec-retry'
27
+ require 'rspec/retry'
41
28
 
42
- Quarantine.bind({database: :dynamodb, aws_region: 'us-west-1'}) # Also accepts aws_credentials to override the standard AWS credential chain
29
+ Quarantine::RSpecAdapter.bind
43
30
 
44
31
  RSpec.configure do |config|
32
+ # Also accepts `:credentials` to override the standard AWS credential chain
33
+ config.quarantine_database = {type: :dynamodb, region: 'us-west-1'}
34
+ # Prevent the list of flaky tests from being polluted by local development and PRs
35
+ config.quarantine_record_tests = ENV["CI"] && ENV["BRANCH"] == "master"
45
36
 
46
- config.around(:each) do |ex|
47
- ex.run_with_retry(retry: 3)
37
+ config.around(:each) do |example|
38
+ example.run_with_retry(retry: 3)
48
39
  end
49
-
50
40
  end
51
41
  ```
52
42
 
53
- Consider wrapping `Quarantine.bind` in if statements so local flaky tests don't pollute the list of quarantined tests
43
+ Quarantine comes with a CLI tool for setting up the necessary table in DynamoDB, if you use that database.
54
44
 
55
- ```rb
56
- if ENV[CI] && ENV[BRANCH] == "master"
57
- Quarantine.bind({database: :dynamodb, aws_region: 'us-west-1'})
58
- end
59
- ```
60
-
61
- Setup tables in AWS DynamoDB to support pulling and uploading quarantined tests
62
45
  ```sh
63
- bundle exec quarantine_dynamodb -h # see all options
46
+ bundle exec quarantine_dynamodb -h # See all options
64
47
 
65
- bundle exec quarantine_dynamodb \ # create the tables in us-west-1 in aws dynamodb
66
- --aws_region us-west-1 # with "quarantine_list" and "master_failed_tests"
67
- # table names
48
+ bundle exec quarantine_dynamodb \ # Create the "test_statuses" table in us-west-1 in AWS DynamoDB
49
+ --region us-west-1
68
50
  ```
69
51
 
70
- You are all set to start quarantining tests!
52
+ ## How It Works
53
+
54
+ A flaky test fails on the first run, but passes after being retried via RSpec::Retry.
71
55
 
72
- ## Try Quarantining Tests Locally
73
- Add a test that will flake
74
56
  ```rb
75
57
  require "spec_helper"
76
58
 
77
59
  describe Quarantine do
78
- it "this test should flake 33% of the time" do
79
- Random.rand(3).to eq(3)
60
+ it "fails on the first run" do
61
+ raise "error" if RSpec.current_example.attempts == 0
62
+ # otherwise, pass
80
63
  end
81
64
  end
82
65
  ```
83
66
 
84
- Run `rspec` on the test
85
67
  ```sh
86
- CI=1 BRANCH=master rspec <filename>
68
+ $ CI=1 BRANCH=master bundle exec rspec <filename>
69
+ [quarantine] Quarantined tests:
70
+ ./bar_spec.rb[1:1] Quarantine fails on the first run
87
71
  ```
88
72
 
89
- If the test fails and passes on the test run (rspec-retry re-ran the test), the test should be quarantined and uploaded to DynamoDB. Check the `quarantine_list` table in DynamoDB.
73
+ When the build completes, all test statuses are written to the database. Flaky tests are marked `quarantined`, and will be executed in future builds, but any failures will be ignored.
74
+
75
+ A test can be removed from quarantine by updating the database manually (outside this gem), or by configuring `quarantine_release_at_consecutive_passes` to remove it after it passes on a certain number of builds in a row.
90
76
 
91
77
  ## Configuration
92
78
 
93
- Go to `spec/spec_helper.rb` and set configuration variables through:
79
+ In `spec_helper.rb`, you can set configuration variables by doing:
80
+
94
81
  ```rb
95
82
  RSpec.configure do |config|
96
- config.VAR_NAME = VALUE
83
+ config.VAR_NAME = VALUE
97
84
  end
98
85
  ```
99
- - Table name for quarantined tests `:quarantine_list_table, default: "quarantine_list"`
100
86
 
101
- - Table name where failed test are uploaded `:quarantine_failed_tests_table, default: "master_failed_tests"`
87
+ - `quarantine_database`: Database configuration (see below), default: `{ type: :dynamodb, region: 'us-west-1' }`
88
+ - `test_statusus_table`: Table name for test statuses, default: `"test_statuses"`
89
+ - `skip_quarantined_tests`: Skipping quarantined tests during test runs default: `true`
90
+ - `quarantine_record_tests`: Recording test statuses, default: `true`
91
+ - `quarantine_logging`: Outputting quarantined gem info, default: `true`
92
+ - `quarantine_extra_attributes`: Storing custom per-example attributes in the table, default: `nil`
93
+ - `quarantine_failsafe_limit`: A failsafe limit of quarantined tests in a single run, default: `10`
94
+ - `quarantine_release_at_consecutive_passes`: Releasing a test from quarantine after it records enough consecutive passes (`nil` to disable this feature), default: `nil`
102
95
 
103
- - Skipping quarantined tests during test runs `:skip_quarantined_tests, default: true`
96
+ ### Databases
104
97
 
105
- - Recording failed tests `:quarantine_record_failed_tests, default: true`
98
+ Quarantine comes with built-in support for the following database types:
99
+ - `:dynamodb`
106
100
 
107
- - Recording flaky tests `:quarantine_record_flaky_tests, default: true`
101
+ To use `:dynamodb`, be sure to add `gem 'aws-sdk-dynamodb', '~> 1', group: :test` to your `Gemfile`.
108
102
 
109
- - Outputting quarantined gem info `:quarantine_logging, default: true`
103
+ To use a custom database that's not provided, subclass `Quarantine::Databases::Base` and pass an instance of your class as the `quarantine_database` setting:
110
104
 
111
- ---
112
- ## Setup Jira Workflow
105
+ ```rb
106
+ class MyDatabase < Quarantine::Databases::Base
107
+ ...
108
+ end
113
109
 
114
- To automatically create Jira tickets, take a look at: `examples/create_tickets.rb`
110
+ RSpec.configure do |config|
111
+ config.quarantine_database = MyDatabase.new(...)
112
+ end
113
+ ```
115
114
 
116
- To automatically unquarantine tests on Jira ticket completion, take a look at: `examples/unquarantine.rb`
115
+ ### Extra attributes
116
+
117
+ Use `quarantine_extra_attributes` to store custom data with each test in the database, e.g. variables useful for your CI setup.
118
+
119
+ ```rb
120
+ config.quarantine_extra_attributes = Proc.new do |example|
121
+ {
122
+ build_url: ENV['BUILDKITE_BUILD_URL'],
123
+ job_id: ENV['BUILDKITE_JOB_ID'],
124
+ }
125
+ end
126
+ ```
117
127
 
118
- ---
119
- ## Contributing
120
- 1. Create an issue regarding a bug fix or feature request
121
- 2. Fork the repository
122
- 3. Create your feature branch, commit changes and create a pull request
123
-
124
- ## Contributors
125
- - [Eric Zhu](https://github.com/eric-zhu-uw)
126
- - [Kevin Miller](https://github.com/Gasparila)
127
- - [Nicholas Silva](https://github.com/flexportnes)
128
- - [Ankur Dahiya](https://github.com/legalosLOTR)
129
128
  ---
130
129
 
131
130
  ## FAQs
132
131
 
133
132
  #### Why are quarantined tests not being skipped locally?
134
133
 
135
- The `quarantine` gem may be configured to only run on certain environments. Make sure you pass all these `ENV` variables to `rspec` when you call it locally
134
+ Quarantine may be configured to only run in certain environments. Check your `spec_helper.rb`, and make sure you have all necessary environment variables set, e.g.:
136
135
 
137
136
  ```sh
138
- CI="1" BRANCH="master" rspec
137
+ CI=1 BRANCH=master bundle exec rspec
139
138
  ```
140
139
 
141
- #### Why is dynamodb failing to connect?
140
+ #### Why is Quarantine failing to connect to DynamoDB?
142
141
 
143
- The AWS client loads credentials from the following locations (in order of precedence):
144
- - The optional `aws_credentials` parameter passed into `Quarantine.bind`
142
+ The AWS client attempts to loads credentials from the following locations, in order:
143
+ - The optional `credentials` field in `RSpec.configuration.quarantine_database`
145
144
  - `ENV['AWS_ACCESS_KEY_ID']` and `ENV['AWS_SECRET_ACCESS_KEY']`
146
145
  - `Aws.config[:credentials]`
147
146
  - The shared credentials ini file at `~/.aws/credentials`
148
147
 
149
- To get AWS credentials, please contact your AWS administrator to get access to dynamodb and create your credentials through IAM.
148
+ More detailed information can be found in the [AWS SDK documentation](https://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Client.html)
149
+
150
+ #### Why is `example.clear_exception` failing locally?
150
151
 
151
- More detailed information can be found: [AWS documentation](https://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Client.html)
152
+ `Example#clear_exception` is an attribute added through [RSpec::Retry](https://github.com/NoRedInk/rspec-retry). Make sure it has been installed and configured.
data/lib/quarantine.rb CHANGED
@@ -1,3 +1,7 @@
1
+ # typed: strict
2
+
3
+ require 'sorbet-runtime'
4
+
1
5
  require 'rspec/retry'
2
6
  require 'quarantine/rspec_adapter'
3
7
  require 'quarantine/test'
@@ -7,146 +11,146 @@ require 'quarantine/databases/dynamo_db'
7
11
  module RSpec
8
12
  module Core
9
13
  class Example
14
+ extend T::Sig
15
+
10
16
  # The implementation of clear_exception in rspec-retry doesn't work
11
17
  # for examples that use `it_behaves_like`, so we implement our own version that
12
18
  # clear the exception field recursively.
19
+ sig { void }
13
20
  def clear_exception!
14
- @exception = nil
15
- example.clear_exception! if defined?(example)
21
+ @exception = T.let(nil, T.untyped)
22
+ T.unsafe(self).example.clear_exception! if defined?(example)
16
23
  end
17
24
  end
18
25
  end
19
26
  end
20
27
 
21
28
  class Quarantine
22
- extend RSpecAdapter
23
-
24
- attr_accessor :database
25
- attr_reader :quarantine_map
26
- attr_reader :failed_tests
27
- attr_reader :flaky_tests
28
- attr_reader :duplicate_tests
29
- attr_reader :buildkite_build_number
30
- attr_reader :summary
31
-
32
- def initialize(options = {})
33
- case options[:database]
34
- # default database option is dynamodb
35
- when :dynamodb, nil
36
- @database = Quarantine::Databases::DynamoDB.new(options)
37
- else
38
- raise Quarantine::UnsupportedDatabaseError.new("Quarantine does not support #{options[:database]}")
39
- end
29
+ extend T::Sig
30
+
31
+ sig { returns(T::Hash[String, Quarantine::Test]) }
32
+ attr_reader :tests
33
+
34
+ sig { returns(T::Hash[String, Quarantine::Test]) }
35
+ attr_reader :old_tests
40
36
 
41
- @quarantine_map = {}
42
- @failed_tests = []
43
- @flaky_tests = []
44
- @buildkite_build_number = ENV['BUILDKITE_BUILD_NUMBER'] || '-1'
45
- @summary = { id: 'quarantine', quarantined_tests: [], flaky_tests: [], database_failures: [] }
37
+ sig { params(options: T::Hash[T.untyped, T.untyped]).void }
38
+ def initialize(options)
39
+ @options = options
40
+ @old_tests = T.let({}, T::Hash[String, Quarantine::Test])
41
+ @tests = T.let({}, T::Hash[String, Quarantine::Test])
42
+ @database_failures = T.let([], T::Array[String])
43
+ @database = T.let(nil, T.nilable(Quarantine::Databases::Base))
46
44
  end
47
45
 
48
- # Scans the quarantine_list from the database and store the individual tests in quarantine_map
49
- def fetch_quarantine_list
46
+ sig { returns(Quarantine::Databases::Base) }
47
+ def database
48
+ @database ||=
49
+ case @options[:database]
50
+ when Quarantine::Databases::Base
51
+ @options[:database]
52
+ else
53
+ database_options = @options[:database].dup
54
+ type = database_options.delete(:type)
55
+ case type
56
+ when :dynamodb
57
+ Quarantine::Databases::DynamoDB.new(database_options)
58
+ else
59
+ raise Quarantine::UnsupportedDatabaseError.new("Quarantine does not support database type: #{type.inspect}")
60
+ end
61
+ end
62
+ end
63
+
64
+ # Scans the test_statuses from the database and store their IDs in quarantined_ids
65
+ sig { void }
66
+ def fetch_test_statuses
50
67
  begin
51
- quarantine_list = database.scan(RSpec.configuration.quarantine_list_table)
68
+ test_statuses = database.fetch_items(@options[:test_statuses_table_name])
52
69
  rescue Quarantine::DatabaseError => e
53
- add_to_summary(:database_failures, "#{e&.cause&.class}: #{e&.cause&.message}")
70
+ @database_failures << "#{e.cause&.class}: #{e.cause&.message}"
54
71
  raise Quarantine::DatabaseError.new(
55
72
  <<~ERROR_MSG
56
- Failed to pull the quarantine list from #{RSpec.configuration.quarantine_list_table}
57
- because of #{e&.cause&.class}: #{e&.cause&.message}
73
+ Failed to pull the quarantine list from #{@options[:test_statuses_table_name]}
74
+ because of #{e.cause&.class}: #{e.cause&.message}
58
75
  ERROR_MSG
59
76
  )
60
77
  end
61
78
 
62
- quarantine_list.each do |example|
63
- # on the rare occassion there are duplicate tests ids in the quarantine_list,
64
- # quarantine the most recent instance of the test (det. through build_number)
65
- # and ignore the older instance of the test
66
- next if
67
- quarantine_map.key?(example['id']) &&
68
- example['build_number'].to_i < quarantine_map[example['id']].build_number.to_i
69
-
70
- quarantine_map.store(
71
- example['id'],
72
- Quarantine::Test.new(example['id'], example['full_description'], example['location'], example['build_number'])
73
- )
74
- end
75
- end
79
+ pairs =
80
+ test_statuses
81
+ .group_by { |t| t['id'] }
82
+ .map { |_id, tests| tests.max_by { |t| t['created_at'] } }
83
+ .compact
84
+ .filter { |t| t['last_status'] == 'quarantined' }
85
+ .map do |t|
86
+ [
87
+ t['id'],
88
+ Quarantine::Test.new(
89
+ id: t['id'],
90
+ status: t['last_status'].to_sym,
91
+ consecutive_passes: t['consecutive_passes'].to_i,
92
+ full_description: t['full_description'],
93
+ location: t['location'],
94
+ extra_attributes: t['extra_attributes']
95
+ )
96
+ ]
97
+ end
76
98
 
77
- # Based off the type, upload a list of tests to a particular database table
78
- def upload_tests(type)
79
- if type == :failed
80
- tests = failed_tests
81
- table_name = RSpec.configuration.quarantine_failed_tests_table
82
- elsif type == :flaky
83
- tests = flaky_tests
84
- table_name = RSpec.configuration.quarantine_list_table
85
- else
86
- raise Quarantine::UnknownUploadError.new(
87
- "Quarantine gem did not know how to handle #{type} upload of tests to dynamodb"
88
- )
89
- end
99
+ @old_tests = Hash[pairs]
100
+ end
90
101
 
91
- return unless tests.length < 10 && tests.length > 0
102
+ sig { void }
103
+ def upload_tests
104
+ return if @tests.empty? || @tests.values.count { |test| test.status == :quarantined } >= @options[:failsafe_limit]
92
105
 
93
106
  begin
94
107
  timestamp = Time.now.to_i / 1000 # Truncated millisecond from timestamp for reasons specific to Flexport
95
- database.batch_write_item(
96
- table_name,
97
- tests,
98
- {
99
- build_job_id: ENV['BUILDKITE_JOB_ID'] || '-1',
100
- created_at: timestamp,
101
- updated_at: timestamp
102
- }
108
+ database.write_items(
109
+ @options[:test_statuses_table_name],
110
+ @tests.values.map { |item| item.to_hash.merge(updated_at: timestamp) }
103
111
  )
104
112
  rescue Quarantine::DatabaseError => e
105
- add_to_summary(:database_failures, "#{e&.cause&.class}: #{e&.cause&.message}")
113
+ @database_failures << "#{e.cause&.class}: #{e.cause&.message}"
106
114
  end
107
115
  end
108
116
 
109
117
  # Param: RSpec::Core::Example
110
- # Add the example to the internal failed tests list
111
- def record_failed_test(example)
112
- failed_tests << Quarantine::Test.new(
113
- example.id,
114
- example.full_description,
115
- example.location,
116
- buildkite_build_number
117
- )
118
- end
119
-
120
- # Param: RSpec::Core::Example
121
- # Add the example to the internal flaky tests list
122
- def record_flaky_test(example)
123
- flaky_test = Quarantine::Test.new(
124
- example.id,
125
- example.full_description,
126
- example.location,
127
- buildkite_build_number
118
+ # Add the example to the internal tests list
119
+ sig { params(example: T.untyped, status: Symbol, passed: T::Boolean).void }
120
+ def record_test(example, status, passed:)
121
+ extra_attributes = @options[:extra_attributes] ? @options[:extra_attributes].call(example) : {}
122
+
123
+ new_consecutive_passes = passed ? (@old_tests[example.id]&.consecutive_passes || 0) + 1 : 0
124
+ release_at = @options[:release_at_consecutive_passes]
125
+ new_status = !release_at.nil? && new_consecutive_passes >= release_at ? :passing : status
126
+ test = Quarantine::Test.new(
127
+ id: example.id,
128
+ status: new_status,
129
+ consecutive_passes: new_consecutive_passes,
130
+ full_description: example.full_description,
131
+ location: example.location,
132
+ extra_attributes: extra_attributes
128
133
  )
129
134
 
130
- flaky_tests << flaky_test
131
- add_to_summary(:flaky_tests, flaky_test.id)
132
- end
133
-
134
- # Param: RSpec::Core::Example
135
- # Clear exceptions on a flaky tests that has been quarantined
136
- def pass_flaky_test(example)
137
- example.clear_exception!
138
- add_to_summary(:quarantined_tests, example.id)
135
+ @tests[test.id] = test
139
136
  end
140
137
 
141
138
  # Param: RSpec::Core::Example
142
- # Check the internal quarantine_map to see if this test should be quarantined
139
+ # Check the internal old_tests to see if this test should be quarantined
140
+ sig { params(example: T.untyped).returns(T::Boolean) }
143
141
  def test_quarantined?(example)
144
- quarantine_map.key?(example.id)
142
+ @old_tests[example.id]&.status == :quarantined
145
143
  end
146
144
 
147
- # Param: Symbol, Any
148
- # Adds the item to the specified attribute in summary
149
- def add_to_summary(attribute, item)
150
- summary[attribute] << item if summary.key?(attribute)
145
+ sig { returns(String) }
146
+ def summary
147
+ quarantined_tests = @tests.values.select { |test| test.status == :quarantined }.sort_by(&:id)
148
+ <<~MESSAGE
149
+ \n[quarantine] Quarantined tests:
150
+ #{quarantined_tests.map { |test| "#{test.id} #{test.full_description}" }.join("\n ")}
151
+
152
+ [quarantine] Database errors:
153
+ #{@database_failures.join("\n ")}
154
+ MESSAGE
151
155
  end
152
156
  end
@@ -1,43 +1,42 @@
1
+ # typed: strict
2
+
1
3
  require 'optparse'
2
4
  require_relative 'databases/base'
3
5
  require_relative 'databases/dynamo_db'
4
6
 
5
7
  class Quarantine
6
8
  class CLI
7
- attr_accessor :options
9
+ extend T::Sig
10
+
11
+ sig { returns(T::Hash[T.untyped, T.untyped]) }
12
+ attr_reader :options
8
13
 
14
+ sig { void }
9
15
  def initialize
10
16
  # default options
11
- @options = {
12
- quarantine_list_table_name: 'quarantine_list',
13
- failed_test_table_name: 'master_failed_tests'
14
- }
17
+ @options = T.let(
18
+ {
19
+ test_statuses_table_name: 'test_statuses'
20
+ }, T::Hash[Symbol, T.untyped]
21
+ )
15
22
  end
16
23
 
24
+ sig { void }
17
25
  def parse
18
26
  OptionParser.new do |parser|
19
27
  parser.banner = 'Usage: quarantine_dynamodb [options]'
20
28
 
21
- parser.on('-rREGION', '--aws_region=REGION', String, 'Specify the aws region for DynamoDB') do |aws_region|
22
- options[:aws_region] = aws_region
29
+ parser.on('-rREGION', '--region=REGION', String, 'Specify the aws region for DynamoDB') do |region|
30
+ @options[:region] = region
23
31
  end
24
32
 
25
33
  parser.on(
26
34
  '-qTABLE',
27
35
  '--quarantine_table=TABLE',
28
36
  String,
29
- "Specify the table name for the quarantine list | Default: #{options[:quarantine_list_table_name]}"
30
- ) do |table_name|
31
- options[:quarantine_list_table_name] = table_name
32
- end
33
-
34
- parser.on(
35
- '-fTABLE',
36
- '--failed_table=TABLE',
37
- String,
38
- "Specify the table name for the failed test list | Default: #{options[:failed_test_table_name]}"
37
+ "Specify the table name for the quarantine list | Default: #{@options[:test_statuses_table_name]}"
39
38
  ) do |table_name|
40
- options[:failed_test_table_name] = table_name
39
+ @options[:test_statuses_table_name] = table_name
41
40
  end
42
41
 
43
42
  parser.on('-h', '--help', 'Prints help page') do
@@ -46,7 +45,7 @@ class Quarantine
46
45
  end
47
46
  end.parse!
48
47
 
49
- if options[:aws_region].nil?
48
+ if @options[:region].nil?
50
49
  error_msg = 'Failed to specify the required aws region with -r option'.freeze
51
50
  warn error_msg
52
51
  raise ArgumentError.new(error_msg)
@@ -54,12 +53,12 @@ class Quarantine
54
53
  end
55
54
 
56
55
  # TODO: eventually move to a separate file & create_table by db type when my db adapters
56
+ sig { void }
57
57
  def create_tables
58
- dynamodb = Quarantine::Databases::DynamoDB.new(options)
58
+ dynamodb = Quarantine::Databases::DynamoDB.new(region: @options[:region])
59
59
 
60
60
  attributes = [
61
- { attribute_name: 'id', attribute_type: 'S', key_type: 'HASH' },
62
- { attribute_name: 'build_number', attribute_type: 'S', key_type: 'RANGE' }
61
+ { attribute_name: 'id', attribute_type: 'S', key_type: 'HASH' }
63
62
  ]
64
63
 
65
64
  additional_arguments = {
@@ -70,15 +69,9 @@ class Quarantine
70
69
  }
71
70
 
72
71
  begin
73
- dynamodb.create_table(options[:quarantine_list_table_name], attributes, additional_arguments)
74
- rescue Quarantine::DatabaseError => e
75
- warn "#{e&.cause&.class}: #{e&.cause&.message}"
76
- end
77
-
78
- begin
79
- dynamodb.create_table(options[:failed_test_table_name], attributes, additional_arguments)
72
+ dynamodb.create_table(@options[:test_statuses_table_name], attributes, additional_arguments)
80
73
  rescue Quarantine::DatabaseError => e
81
- warn "#{e&.cause&.class}: #{e&.cause&.message}"
74
+ warn "#{e.cause&.class}: #{e.cause&.message}"
82
75
  end
83
76
  end
84
77
  end
@@ -1,17 +1,34 @@
1
+ # typed: strict
2
+
1
3
  class Quarantine
2
4
  module Databases
3
5
  class Base
4
- def initialize
5
- raise NotImplementedError
6
- end
6
+ extend T::Sig
7
+ extend T::Helpers
7
8
 
8
- def scan
9
- raise NotImplementedError
9
+ abstract!
10
+
11
+ Item = T.type_alias do
12
+ {
13
+ 'id' => String,
14
+ 'last_status' => String,
15
+ 'consecutive_passes' => Integer,
16
+ 'full_description' => String,
17
+ 'location' => String,
18
+ 'extra_attributes' => T.untyped
19
+ }
10
20
  end
11
21
 
12
- def batch_write_item
13
- raise NotImplementedError
22
+ sig { abstract.params(table_name: String).returns(T::Enumerable[Item]) }
23
+ def fetch_items(table_name); end
24
+
25
+ sig do
26
+ abstract.params(
27
+ table_name: String,
28
+ items: T::Array[Item]
29
+ ).void
14
30
  end
31
+ def write_items(table_name, items); end
15
32
  end
16
33
  end
17
34
  end
@@ -1,51 +1,53 @@
1
- require 'aws-sdk-dynamodb'
1
+ # typed: strict
2
+
3
+ begin
4
+ require 'aws-sdk-dynamodb'
5
+ rescue LoadError
6
+ end
2
7
  require 'quarantine/databases/base'
3
8
  require 'quarantine/error'
4
9
 
5
10
  class Quarantine
6
11
  module Databases
7
12
  class DynamoDB < Base
13
+ extend T::Sig
14
+
15
+ Attribute = T.type_alias { { attribute_name: String, attribute_type: String, key_type: String } }
16
+
17
+ sig { returns(Aws::DynamoDB::Client) }
8
18
  attr_accessor :dynamodb
9
19
 
10
- def initialize(aws_region: 'us-west-1', aws_credentials: nil, **_additional_arguments)
11
- options = { region: aws_region }
12
- options[:credentials] = aws_credentials if aws_credentials
20
+ sig { params(options: T::Hash[T.untyped, T.untyped]).void }
21
+ def initialize(options)
22
+ super()
13
23
 
14
- @dynamodb = Aws::DynamoDB::Client.new(options)
24
+ @dynamodb = T.let(Aws::DynamoDB::Client.new(options), Aws::DynamoDB::Client)
15
25
  end
16
26
 
17
- def scan(table_name)
27
+ sig { override.params(table_name: String).returns(T::Enumerable[Item]) }
28
+ def fetch_items(table_name)
18
29
  begin
19
- result = dynamodb.scan(table_name: table_name)
30
+ result = @dynamodb.scan(table_name: table_name)
20
31
  rescue Aws::DynamoDB::Errors::ServiceError
21
32
  raise Quarantine::DatabaseError
22
33
  end
23
34
 
24
- result&.items
35
+ result.items
25
36
  end
26
37
 
27
- def batch_write_item(table_name, items, additional_attributes = {}, dedup_keys = %w[id full_description])
28
- return if items.empty?
29
-
30
- # item_a is a duplicate of item_b if all values for each dedup_key in both item_a and item_b match
31
- is_a_duplicate = ->(item_a, item_b) { dedup_keys.all? { |key| item_a[key] == item_b[key] } }
32
-
33
- scanned_items = scan(table_name)
34
-
35
- deduped_items = items.reject do |item|
36
- scanned_items.any? do |scanned_item|
37
- is_a_duplicate.call(item.to_string_hash, scanned_item)
38
- end
39
- end
40
-
41
- return if deduped_items.empty?
42
-
43
- dynamodb.batch_write_item(
38
+ sig do
39
+ override.params(
40
+ table_name: String,
41
+ items: T::Array[Item]
42
+ ).void
43
+ end
44
+ def write_items(table_name, items)
45
+ @dynamodb.batch_write_item(
44
46
  request_items: {
45
- table_name => deduped_items.map do |item|
47
+ table_name => items.map do |item|
46
48
  {
47
49
  put_request: {
48
- item: { **item.to_hash, **additional_attributes }
50
+ item: item
49
51
  }
50
52
  }
51
53
  end
@@ -55,8 +57,9 @@ class Quarantine
55
57
  raise Quarantine::DatabaseError
56
58
  end
57
59
 
58
- def delete_item(table_name, keys)
59
- dynamodb.delete_item(
60
+ sig { params(table_name: String, keys: T::Hash[T.untyped, T.untyped]).void }
61
+ def delete_items(table_name, keys)
62
+ @dynamodb.delete_item(
60
63
  table_name: table_name,
61
64
  key: {
62
65
  **keys
@@ -66,8 +69,15 @@ class Quarantine
66
69
  raise Quarantine::DatabaseError
67
70
  end
68
71
 
72
+ sig do
73
+ params(
74
+ table_name: String,
75
+ attributes: T::Array[Attribute],
76
+ additional_arguments: T::Hash[T.untyped, T.untyped]
77
+ ).void
78
+ end
69
79
  def create_table(table_name, attributes, additional_arguments = {})
70
- dynamodb.create_table(
80
+ @dynamodb.create_table(
71
81
  {
72
82
  table_name: table_name,
73
83
  attribute_definitions: attributes.map do |attribute|
@@ -1,3 +1,5 @@
1
+ # typed: strict
2
+
1
3
  class Quarantine
2
4
  class Error < StandardError; end
3
5
 
@@ -1,86 +1,105 @@
1
- module RSpecAdapter
1
+ # typed: strict
2
+
3
+ module Quarantine::RSpecAdapter # rubocop:disable Style/ClassAndModuleChildren
4
+ extend T::Sig
5
+
2
6
  # Purpose: create an instance of Quarantine which contains information
3
7
  # about the test suite (ie. quarantined tests) and binds RSpec configurations
4
8
  # and hooks onto the global RSpec class
5
- def bind(options = {})
6
- quarantine = Quarantine.new(options)
9
+ sig { void }
10
+ def self.bind
7
11
  bind_rspec_configurations
8
- bind_quarantine_list(quarantine)
9
- bind_quarantine_checker(quarantine)
10
- bind_quarantine_record_tests(quarantine)
11
- bind_logger(quarantine)
12
+ bind_fetch_test_statuses
13
+ bind_record_tests
14
+ bind_upload_tests
15
+ bind_logger
12
16
  end
13
17
 
14
- private
18
+ sig { returns(Quarantine) }
19
+ def self.quarantine
20
+ @quarantine = T.let(@quarantine, T.nilable(Quarantine))
21
+ @quarantine ||= Quarantine.new(
22
+ database: RSpec.configuration.quarantine_database,
23
+ test_statuses_table_name: RSpec.configuration.quarantine_test_statuses,
24
+ extra_attributes: RSpec.configuration.quarantine_extra_attributes,
25
+ failsafe_limit: RSpec.configuration.quarantine_failsafe_limit
26
+ )
27
+ end
15
28
 
16
29
  # Purpose: binds rspec configuration variables
17
- def bind_rspec_configurations
30
+ sig { void }
31
+ def self.bind_rspec_configurations
18
32
  ::RSpec.configure do |config|
19
- config.add_setting(:quarantine_list_table, { default: 'quarantine_list' })
20
- config.add_setting(:quarantine_failed_tests_table, { default: 'master_failed_tests' })
33
+ config.add_setting(:quarantine_database, default: { type: :dynamodb, region: 'us-west-1' })
34
+ config.add_setting(:quarantine_test_statuses, { default: 'test_statuses' })
21
35
  config.add_setting(:skip_quarantined_tests, { default: true })
22
- config.add_setting(:quarantine_record_failed_tests, { default: true })
23
- config.add_setting(:quarantine_record_flaky_tests, { default: true })
36
+ config.add_setting(:quarantine_record_tests, { default: true })
24
37
  config.add_setting(:quarantine_logging, { default: true })
38
+ config.add_setting(:quarantine_extra_attributes)
39
+ config.add_setting(:quarantine_failsafe_limit, default: 10)
40
+ config.add_setting(:quarantine_release_at_consecutive_passes)
25
41
  end
26
42
  end
27
43
 
28
- # Purpose: binds quarantine to fetch the quarantine_list from dynamodb in the before suite
29
- def bind_quarantine_list(quarantine)
44
+ # Purpose: binds quarantine to fetch the test_statuses from dynamodb in the before suite
45
+ sig { void }
46
+ def self.bind_fetch_test_statuses
30
47
  ::RSpec.configure do |config|
31
48
  config.before(:suite) do
32
- quarantine.fetch_quarantine_list
49
+ Quarantine::RSpecAdapter.quarantine.fetch_test_statuses
33
50
  end
34
51
  end
35
52
  end
36
53
 
37
- # Purpose: binds quarantine to skip and pass tests that have been quarantined in the after suite
38
- def bind_quarantine_checker(quarantine)
39
- ::RSpec.configure do |config|
40
- config.after(:each) do |example|
41
- if RSpec.configuration.skip_quarantined_tests && quarantine.test_quarantined?(example)
42
- quarantine.pass_flaky_test(example)
43
- end
44
- end
45
- end
46
- end
47
-
48
- # Purpose: binds quarantine to record failed and flaky tests
49
- def bind_quarantine_record_tests(quarantine)
54
+ # Purpose: binds quarantine to record test statuses
55
+ sig { void }
56
+ def self.bind_record_tests
50
57
  ::RSpec.configure do |config|
51
58
  config.after(:each) do |example|
52
59
  metadata = example.metadata
53
60
 
54
- # will record the failed test if is not quarantined and it is on it's final retry from the rspec-retry gem
55
- quarantine.record_failed_test(example) if
56
- RSpec.configuration.quarantine_record_failed_tests &&
57
- !quarantine.test_quarantined?(example) &&
58
- metadata[:retry_attempts] + 1 == metadata[:retry] && example.exception
59
-
60
- # will record the flaky test if is not quarantined and it failed the first run but passed a subsequent run;
61
61
  # optionally, the upstream RSpec configuration could define an after hook that marks an example as flaky in
62
62
  # the example's metadata
63
- quarantine.record_flaky_test(example) if
64
- RSpec.configuration.quarantine_record_flaky_tests &&
65
- !quarantine.test_quarantined?(example) &&
66
- (metadata[:retry_attempts] > 0 && example.exception.nil?) || metadata[:flaky]
63
+ quarantined = Quarantine::RSpecAdapter.quarantine.test_quarantined?(example) || metadata[:flaky]
64
+ if example.exception
65
+ if metadata[:retry_attempts] + 1 == metadata[:retry]
66
+ # will record the failed test if it's final retry from the rspec-retry gem
67
+ if RSpec.configuration.skip_quarantined_tests && quarantined
68
+ example.clear_exception!
69
+ Quarantine::RSpecAdapter.quarantine.record_test(example, :quarantined, passed: false)
70
+ else
71
+ Quarantine::RSpecAdapter.quarantine.record_test(example, :failing, passed: false)
72
+ end
73
+ end
74
+ elsif metadata[:retry_attempts] > 0
75
+ # will record the flaky test if it failed the first run but passed a subsequent run
76
+ Quarantine::RSpecAdapter.quarantine.record_test(example, :quarantined, passed: false)
77
+ elsif quarantined
78
+ Quarantine::RSpecAdapter.quarantine.record_test(example, :quarantined, passed: true)
79
+ else
80
+ Quarantine::RSpecAdapter.quarantine.record_test(example, :passing, passed: true)
81
+ end
67
82
  end
68
83
  end
84
+ end
69
85
 
86
+ sig { void }
87
+ def self.bind_upload_tests
70
88
  ::RSpec.configure do |config|
71
89
  config.after(:suite) do
72
- quarantine.upload_tests(:failed) if RSpec.configuration.quarantine_record_failed_tests
73
-
74
- quarantine.upload_tests(:flaky) if RSpec.configuration.quarantine_record_flaky_tests
90
+ Quarantine::RSpecAdapter.quarantine.upload_tests if RSpec.configuration.quarantine_record_tests
75
91
  end
76
92
  end
77
93
  end
78
94
 
79
95
  # Purpose: binds quarantine logger to output test to RSpec formatter messages
80
- def bind_logger(quarantine)
96
+ sig { void }
97
+ def self.bind_logger
81
98
  ::RSpec.configure do |config|
82
99
  config.after(:suite) do
83
- RSpec.configuration.reporter.message(quarantine.summary) if RSpec.configuration.quarantine_logging
100
+ if RSpec.configuration.quarantine_logging
101
+ RSpec.configuration.reporter.message(Quarantine::RSpecAdapter.quarantine.summary)
102
+ end
84
103
  end
85
104
  end
86
105
  end
@@ -1,32 +1,25 @@
1
+ # typed: strict
2
+
1
3
  class Quarantine
2
- class Test
3
- attr_accessor :id
4
- attr_accessor :full_description
5
- attr_accessor :location
6
- attr_accessor :build_number
4
+ class Test < T::Struct
5
+ extend T::Sig
7
6
 
8
- def initialize(id, full_description, location, build_number)
9
- @id = id
10
- @full_description = full_description
11
- @location = location
12
- @build_number = build_number
13
- end
7
+ const :id, String
8
+ const :status, Symbol
9
+ const :consecutive_passes, Integer
10
+ const :full_description, String
11
+ const :location, String
12
+ const :extra_attributes, T::Hash[T.untyped, T.untyped]
14
13
 
14
+ sig { returns(Quarantine::Databases::Base::Item) }
15
15
  def to_hash
16
- {
17
- id: id,
18
- full_description: full_description,
19
- location: location,
20
- build_number: build_number
21
- }
22
- end
23
-
24
- def to_string_hash
25
16
  {
26
17
  'id' => id,
18
+ 'last_status' => status.to_s,
19
+ 'consecutive_passes' => consecutive_passes,
27
20
  'full_description' => full_description,
28
21
  'location' => location,
29
- 'build_number' => build_number
22
+ 'extra_attributes' => extra_attributes
30
23
  }
31
24
  end
32
25
  end
@@ -1,3 +1,3 @@
1
1
  class Quarantine
2
- VERSION = '1.0.7'.freeze
2
+ VERSION = '2.0.0'.freeze
3
3
  end
data/quarantine.gemspec CHANGED
@@ -11,14 +11,14 @@ Gem::Specification.new do |s|
11
11
  s.version = Quarantine::VERSION
12
12
  s.authors = ['Flexport Engineering, Eric Zhu']
13
13
  s.email = ['ericzhu77@gmail.com']
14
- s.summary = 'Quarantine flaky Ruby Rspec tests'
14
+ s.summary = 'Quarantine flaky RSpec tests'
15
15
  s.homepage = 'https://github.com/flexport/quarantine'
16
16
  s.license = 'MIT'
17
17
  s.files = Dir['{lib, bin}/**/*', '*.md', '*.gemspec']
18
18
  s.executables = ['quarantine_dynamodb']
19
19
  s.required_ruby_version = '>= 2.0'
20
20
 
21
- s.add_dependency('aws-sdk-dynamodb', '~> 1')
22
21
  s.add_dependency('rspec', '~> 3.0')
23
22
  s.add_dependency('rspec-retry', '~> 0.6')
23
+ s.add_dependency('sorbet-runtime', '0.5.6338')
24
24
  end
metadata CHANGED
@@ -1,57 +1,57 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quarantine
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.7
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Flexport Engineering, Eric Zhu
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-12-01 00:00:00.000000000 Z
11
+ date: 2021-04-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: aws-sdk-dynamodb
14
+ name: rspec
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1'
19
+ version: '3.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1'
26
+ version: '3.0'
27
27
  - !ruby/object:Gem::Dependency
28
- name: rspec
28
+ name: rspec-retry
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '3.0'
33
+ version: '0.6'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '3.0'
40
+ version: '0.6'
41
41
  - !ruby/object:Gem::Dependency
42
- name: rspec-retry
42
+ name: sorbet-runtime
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - "~>"
45
+ - - '='
46
46
  - !ruby/object:Gem::Version
47
- version: '0.6'
47
+ version: 0.5.6338
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - "~>"
52
+ - - '='
53
53
  - !ruby/object:Gem::Version
54
- version: '0.6'
54
+ version: 0.5.6338
55
55
  description:
56
56
  email:
57
57
  - ericzhu77@gmail.com
@@ -91,8 +91,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
91
91
  - !ruby/object:Gem::Version
92
92
  version: '0'
93
93
  requirements: []
94
- rubygems_version: 3.0.2
94
+ rubygems_version: 3.0.8
95
95
  signing_key:
96
96
  specification_version: 4
97
- summary: Quarantine flaky Ruby Rspec tests
97
+ summary: Quarantine flaky RSpec tests
98
98
  test_files: []