quarantine 1.0.3 → 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: dce45c3ec38cb39613ea7e1e5dff68524fcbfd3a7005c879985b41ba62343688
4
- data.tar.gz: ba098ba05cc35e0aedc90db91907b1767556718520e11bd8dcb2240b52872342
3
+ metadata.gz: 47aa8e8df1db802259f0107a7a06c2d05162899b803ffd224da8a53246c1e105
4
+ data.tar.gz: 8cb6c6c9c5b79666c10feaf38e0077eab27b57d494dc08e0c385925de1c89583
5
5
  SHA512:
6
- metadata.gz: dda9843f287d437087882386da70ed78876b1592e9e803d6b62d33b609baf366e457b2e91a6ac817ea9617316679845e9fa793deafde01ddd4eb3f481dc5af15
7
- data.tar.gz: 6708068fbac697316d51a07a4b1fcc58e48b832aa074afb8ee7794142206eccbb556f5ca7726d0694816011883f3a321304565e1289036aec2a5ac640e1cf4f6
6
+ metadata.gz: e3f9ba5314c425861f18b7451a7f28f8ced4af50479ad880f0be263a9104ba3c6dd85bcada8a0c89d194e9487d02702c9fe4930caceebf835413891fc31d6a37
7
+ data.tar.gz: 3177827c0ff075302fa8e62c0b3480728769a3cb6199cb8eb0e510237df9839d0efc10db0180d32016d1ced933d9d50f87336f5f2d6ff3f741622a5aed3364b2
data/CHANGELOG.md CHANGED
@@ -1,9 +1,20 @@
1
+ ### 1.0.7
2
+ Support `it_behaves_like` behavior
3
+
4
+ ### 1.0.6
5
+ Update DynamoDB batch_write_item implementation to check for duplicates based on different keys before uploading
6
+
7
+ ### 1.0.5
8
+ Add aws_credentials argument during dynamodb initialization to override the AWS SDK credential chain
9
+
10
+ ### 1.0.4
11
+ Enable upstream callers to mark an example as flaky through the example's metadata
12
+
1
13
  ### 1.0.3
2
- Only require dynamodb instead of full aws-sdk
14
+ Only require dynamodb instead of full aws-sdk
3
15
 
4
16
  ### 1.0.2
5
- Relax Aws gem version constraint as aws-sdk v3 is 100% backwards
6
- compatible with v2
17
+ Relax Aws gem version constraint as aws-sdk v3 is 100% backwards compatible with v2
7
18
 
8
19
  ### 1.0.1
9
- Initial Release
20
+ Initial Release
data/README.md CHANGED
@@ -1,154 +1,152 @@
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'
28
- gem 'rspec-retry
19
+ gem 'rspec-retry'
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'})
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
- - Quarantined tests are not skipped automatically `: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
109
+
110
+ RSpec.configure do |config|
111
+ config.quarantine_database = MyDatabase.new(...)
112
+ end
113
+ ```
113
114
 
114
- To automatically create Jira tickets, take a look at: `examples/create_tickets.rb`
115
+ ### Extra attributes
115
116
 
116
- To automatically unquarantine tests on Jira ticket completion, take a look at: `examples/unquarantine.rb`
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:
142
+ The AWS client attempts to loads credentials from the following locations, in order:
143
+ - The optional `credentials` field in `RSpec.configuration.quarantine_database`
144
144
  - `ENV['AWS_ACCESS_KEY_ID']` and `ENV['AWS_SECRET_ACCESS_KEY']`
145
145
  - `Aws.config[:credentials]`
146
146
  - The shared credentials ini file at `~/.aws/credentials`
147
147
 
148
- To get AWS credentials, please contact your AWS administrator to get access to dynamodb and create your credentials through IAM.
149
-
150
- More detailed information can be found: [AWS documentation](https://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Client.html)
148
+ More detailed information can be found in the [AWS SDK documentation](https://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Client.html)
151
149
 
152
150
  #### Why is `example.clear_exception` failing locally?
153
-
154
- `example.clear_exception` is an attribute added through `rspec_retry`. Make sure `rspec-retry` has been installed and configured.
151
+
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,141 +1,156 @@
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'
4
8
  require 'quarantine/databases/base'
5
9
  require 'quarantine/databases/dynamo_db'
6
10
 
7
- class Quarantine
8
- extend RSpecAdapter
9
-
10
- attr_accessor :database
11
- attr_reader :quarantine_map
12
- attr_reader :failed_tests
13
- attr_reader :flaky_tests
14
- attr_reader :duplicate_tests
15
- attr_reader :buildkite_build_number
16
- attr_reader :summary
17
-
18
- def initialize(options = {})
19
- case options[:database]
20
- # default database option is dynamodb
21
- when :dynamodb, nil
22
- @database = Quarantine::Databases::DynamoDB.new(options)
23
- else
24
- raise Quarantine::UnsupportedDatabaseError.new("Quarantine does not support #{options[:database]}")
11
+ module RSpec
12
+ module Core
13
+ class Example
14
+ extend T::Sig
15
+
16
+ # The implementation of clear_exception in rspec-retry doesn't work
17
+ # for examples that use `it_behaves_like`, so we implement our own version that
18
+ # clear the exception field recursively.
19
+ sig { void }
20
+ def clear_exception!
21
+ @exception = T.let(nil, T.untyped)
22
+ T.unsafe(self).example.clear_exception! if defined?(example)
23
+ end
25
24
  end
25
+ end
26
+ end
27
+
28
+ class Quarantine
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
26
36
 
27
- @quarantine_map = {}
28
- @failed_tests = []
29
- @flaky_tests = []
30
- @buildkite_build_number = ENV['BUILDKITE_BUILD_NUMBER'] || '-1'
31
- @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))
32
44
  end
33
45
 
34
- # Scans the quarantine_list from the database and store the individual tests in quarantine_map
35
- 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
36
67
  begin
37
- quarantine_list = database.scan(RSpec.configuration.quarantine_list_table)
68
+ test_statuses = database.fetch_items(@options[:test_statuses_table_name])
38
69
  rescue Quarantine::DatabaseError => e
39
- add_to_summary(:database_failures, "#{e&.cause&.class}: #{e&.cause&.message}")
70
+ @database_failures << "#{e.cause&.class}: #{e.cause&.message}"
40
71
  raise Quarantine::DatabaseError.new(
41
72
  <<~ERROR_MSG
42
- Failed to pull the quarantine list from #{RSpec.configuration.quarantine_list_table}
43
- 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}
44
75
  ERROR_MSG
45
76
  )
46
77
  end
47
78
 
48
- quarantine_list.each do |example|
49
- # on the rare occassion there are duplicate tests ids in the quarantine_list,
50
- # quarantine the most recent instance of the test (det. through build_number)
51
- # and ignore the older instance of the test
52
- next if
53
- quarantine_map.key?(example['id']) &&
54
- example['build_number'].to_i < quarantine_map[example['id']].build_number.to_i
55
-
56
- quarantine_map.store(
57
- example['id'],
58
- Quarantine::Test.new(example['id'], example['full_description'], example['location'], example['build_number'])
59
- )
60
- 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
98
+
99
+ @old_tests = Hash[pairs]
61
100
  end
62
101
 
63
- # Based off the type, upload a list of tests to a particular database table
64
- def upload_tests(type)
65
- if type == :failed
66
- tests = failed_tests
67
- table_name = RSpec.configuration.quarantine_failed_tests_table
68
- elsif type == :flaky
69
- tests = flaky_tests
70
- table_name = RSpec.configuration.quarantine_list_table
71
- else
72
- raise Quarantine::UnknownUploadError.new(
73
- "Quarantine gem did not know how to handle #{type} upload of tests to dynamodb"
74
- )
75
- end
76
-
77
- 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]
78
105
 
79
106
  begin
80
107
  timestamp = Time.now.to_i / 1000 # Truncated millisecond from timestamp for reasons specific to Flexport
81
- database.batch_write_item(
82
- table_name,
83
- tests,
84
- {
85
- build_job_id: ENV['BUILDKITE_JOB_ID'] || '-1',
86
- created_at: timestamp,
87
- updated_at: timestamp
88
- }
108
+ database.write_items(
109
+ @options[:test_statuses_table_name],
110
+ @tests.values.map { |item| item.to_hash.merge(updated_at: timestamp) }
89
111
  )
90
112
  rescue Quarantine::DatabaseError => e
91
- add_to_summary(:database_failures, "#{e&.cause&.class}: #{e&.cause&.message}")
113
+ @database_failures << "#{e.cause&.class}: #{e.cause&.message}"
92
114
  end
93
115
  end
94
116
 
95
117
  # Param: RSpec::Core::Example
96
- # Add the example to the internal failed tests list
97
- def record_failed_test(example)
98
- failed_tests << Quarantine::Test.new(
99
- example.id,
100
- example.full_description,
101
- example.location,
102
- buildkite_build_number
103
- )
104
- end
105
-
106
- # Param: RSpec::Core::Example
107
- # Add the example to the internal flaky tests list
108
- def record_flaky_test(example)
109
- flaky_test = Quarantine::Test.new(
110
- example.id,
111
- example.full_description,
112
- example.location,
113
- 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
114
133
  )
115
134
 
116
- flaky_tests << flaky_test
117
- add_to_summary(:flaky_tests, flaky_test.id)
118
- end
119
-
120
- # Param: RSpec::Core::Example
121
- # Clear exceptions on a flaky tests that has been quarantined
122
- #
123
- # example.clear_exception is tightly coupled with the rspec-retry gem and will only exist if
124
- # the rspec-retry gem is enabled
125
- def pass_flaky_test(example)
126
- example.clear_exception
127
- add_to_summary(:quarantined_tests, example.id)
135
+ @tests[test.id] = test
128
136
  end
129
137
 
130
138
  # Param: RSpec::Core::Example
131
- # 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) }
132
141
  def test_quarantined?(example)
133
- quarantine_map.key?(example.id)
142
+ @old_tests[example.id]&.status == :quarantined
134
143
  end
135
144
 
136
- # Param: Symbol, Any
137
- # Adds the item to the specified attribute in summary
138
- def add_to_summary(attribute, item)
139
- 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
140
155
  end
141
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,57 +1,83 @@
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', **_additional_arguments)
11
- @dynamodb = Aws::DynamoDB::Client.new({ region: aws_region })
20
+ sig { params(options: T::Hash[T.untyped, T.untyped]).void }
21
+ def initialize(options)
22
+ super()
23
+
24
+ @dynamodb = T.let(Aws::DynamoDB::Client.new(options), Aws::DynamoDB::Client)
12
25
  end
13
26
 
14
- def scan(table_name)
27
+ sig { override.params(table_name: String).returns(T::Enumerable[Item]) }
28
+ def fetch_items(table_name)
15
29
  begin
16
- result = dynamodb.scan({ table_name: table_name })
30
+ result = @dynamodb.scan(table_name: table_name)
17
31
  rescue Aws::DynamoDB::Errors::ServiceError
18
32
  raise Quarantine::DatabaseError
19
33
  end
20
34
 
21
- result&.items
35
+ result.items
22
36
  end
23
37
 
24
- def batch_write_item(table_name, items, additional_attributes = {})
25
- dynamodb.batch_write_item(
26
- { request_items: {
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(
46
+ request_items: {
27
47
  table_name => items.map do |item|
28
48
  {
29
49
  put_request: {
30
- item: { **item.to_hash, **additional_attributes }
50
+ item: item
31
51
  }
32
52
  }
33
53
  end
34
- } }
54
+ }
35
55
  )
36
56
  rescue Aws::DynamoDB::Errors::ServiceError
37
57
  raise Quarantine::DatabaseError
38
58
  end
39
59
 
40
- def delete_item(table_name, keys)
41
- dynamodb.delete_item(
42
- {
43
- table_name: table_name,
44
- key: {
45
- **keys
46
- }
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(
63
+ table_name: table_name,
64
+ key: {
65
+ **keys
47
66
  }
48
67
  )
49
68
  rescue Aws::DynamoDB::Errors::ServiceError
50
69
  raise Quarantine::DatabaseError
51
70
  end
52
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
53
79
  def create_table(table_name, attributes, additional_arguments = {})
54
- dynamodb.create_table(
80
+ @dynamodb.create_table(
55
81
  {
56
82
  table_name: table_name,
57
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,84 +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
- quarantine.record_flaky_test(example) if
62
- RSpec.configuration.quarantine_record_flaky_tests &&
63
- !quarantine.test_quarantined?(example) &&
64
- metadata[:retry_attempts] > 0 && example.exception.nil?
61
+ # optionally, the upstream RSpec configuration could define an after hook that marks an example as flaky in
62
+ # the example's metadata
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
65
82
  end
66
83
  end
84
+ end
67
85
 
86
+ sig { void }
87
+ def self.bind_upload_tests
68
88
  ::RSpec.configure do |config|
69
89
  config.after(:suite) do
70
- quarantine.upload_tests(:failed) if RSpec.configuration.quarantine_record_failed_tests
71
-
72
- quarantine.upload_tests(:flaky) if RSpec.configuration.quarantine_record_flaky_tests
90
+ Quarantine::RSpecAdapter.quarantine.upload_tests if RSpec.configuration.quarantine_record_tests
73
91
  end
74
92
  end
75
93
  end
76
94
 
77
95
  # Purpose: binds quarantine logger to output test to RSpec formatter messages
78
- def bind_logger(quarantine)
96
+ sig { void }
97
+ def self.bind_logger
79
98
  ::RSpec.configure do |config|
80
99
  config.after(:suite) do
81
- 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
82
103
  end
83
104
  end
84
105
  end
@@ -1,23 +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
16
  {
17
- id: id,
18
- full_description: full_description,
19
- location: location,
20
- build_number: build_number
17
+ 'id' => id,
18
+ 'last_status' => status.to_s,
19
+ 'consecutive_passes' => consecutive_passes,
20
+ 'full_description' => full_description,
21
+ 'location' => location,
22
+ 'extra_attributes' => extra_attributes
21
23
  }
22
24
  end
23
25
  end
@@ -1,3 +1,3 @@
1
1
  class Quarantine
2
- VERSION = '1.0.3'.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,58 +1,58 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quarantine
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Flexport Engineering, Eric Zhu
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-05-14 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'
55
- description:
54
+ version: 0.5.6338
55
+ description:
56
56
  email:
57
57
  - ericzhu77@gmail.com
58
58
  executables:
@@ -76,7 +76,7 @@ homepage: https://github.com/flexport/quarantine
76
76
  licenses:
77
77
  - MIT
78
78
  metadata: {}
79
- post_install_message:
79
+ post_install_message:
80
80
  rdoc_options: []
81
81
  require_paths:
82
82
  - lib
@@ -91,9 +91,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
91
91
  - !ruby/object:Gem::Version
92
92
  version: '0'
93
93
  requirements: []
94
- rubyforge_project:
95
- rubygems_version: 2.7.8
96
- signing_key:
94
+ rubygems_version: 3.0.8
95
+ signing_key:
97
96
  specification_version: 4
98
- summary: Quarantine flaky Ruby Rspec tests
97
+ summary: Quarantine flaky RSpec tests
99
98
  test_files: []