quarantine 2.1.2 → 2.2.2

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: 5b572688cff1561139754374abf14bfc0fbbce203dd7ab8d0db5916b2c5b67ba
4
- data.tar.gz: 8dfc948827ad091c3424dd425ba157294340fff95ec603e76f1ffcc5059541bd
3
+ metadata.gz: 2d156442ce5b26736708dbc75881cf1ba37776d69970a72f661ea10d77448d8f
4
+ data.tar.gz: 576657a82b094339bd91ca64ad9467ed7774ce1e45636d4e5051209f7bf52a6d
5
5
  SHA512:
6
- metadata.gz: 879da05b1135ab244f4246e8003418a0e5b50f68ac7ca408b7ead93ebc44b5b54676d84be4b6793166c84ddf4816f92c09664ad6d78cc02755c7bf4c0de181b9
7
- data.tar.gz: d7d9cc51559136896dde5ad0ebde281542cbe2ed4b0a42a7dfa508a264d070bab67d1ea9dfe1c213e50fa98308e85430791d69e57b301c1d1d11ce3883bcfeab
6
+ metadata.gz: 58eb71c5b9bdc37d0561f49c3e6aaf93407de7f00f76c0823bbf84388853d6c2bf413c72c4f59aba737ba271380f055e30205daf80db337edc0f186bf2f61ca5
7
+ data.tar.gz: 2ce73d002d65bf972228aeaada7bb1cba24a21ff14da8414f112238e8abac6852b198eff9cbb0d146d863e4a48718d69627bf592c61669067d9bd09fe5c9b5c3
data/README.md CHANGED
@@ -101,16 +101,25 @@ Quarantine comes with built-in support for the following database types:
101
101
 
102
102
  To use `:dynamodb`, be sure to add `gem 'aws-sdk-dynamodb', '~> 1', group: :test` to your `Gemfile`.
103
103
 
104
- To use `:google_sheets`, be sure to add `gem 'google_drive', '~> 2', group: :test` to your `Gemfile`. Here's an example:
104
+ To use `:google_sheets`, be sure to add `gem 'google_drive', '~> 3', group: :test` to your `Gemfile`. Here's an example:
105
105
 
106
106
  ```rb
107
107
  config.quarantine_database = {
108
108
  type: :google_sheets,
109
109
  authorization: {type: :service_account_key, file: "service_account.json"}, # also accepts `type: :config`
110
- spreadsheet: {type: :by_key, "1Jb5fC6wSuIMnP85tUR5knuZ4f5fuu4nMzQF6-0l-EXAMPLE"}, # also accepts `type: :by_title` and `type: :by_url`
110
+ spreadsheet: {
111
+ type: :by_key, # also accepts `type: :by_title` and `type: :by_url`
112
+ key: "1Jb5fC6wSuIMnP85tUR5knuZ4f5fuu4nMzQF6-0l-EXAMPLE"}, # also accepts `type: :by_title` and `type: :by_url`
111
113
  }
112
114
  ```
113
115
 
116
+ The spreadsheet first line (1) should contains: id, full_description, updated_at, last_status, location, extra_attributes, consecutive_passes. Something like:
117
+
118
+ A | B | C | D | E | F | G
119
+ -- | :-----------------: | :---------:| :----------:| :-------:| :---------------:| :--:
120
+ id | full_description | updated_at | last_status | location | extra_attributes | consecutive_passes
121
+
122
+
114
123
  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:
115
124
 
116
125
  ```rb
data/lib/quarantine.rb CHANGED
@@ -9,23 +9,6 @@ require 'quarantine/databases/base'
9
9
  require 'quarantine/databases/dynamo_db'
10
10
  require 'quarantine/databases/google_sheets'
11
11
 
12
- module RSpec
13
- module Core
14
- class Example
15
- extend T::Sig
16
-
17
- # The implementation of clear_exception in rspec-retry doesn't work
18
- # for examples that use `it_behaves_like`, so we implement our own version that
19
- # clear the exception field recursively.
20
- sig { void }
21
- def clear_exception!
22
- @exception = T.let(nil, T.untyped)
23
- T.unsafe(self).example.clear_exception! if defined?(example)
24
- end
25
- end
26
- end
27
- end
28
-
29
12
  class Quarantine
30
13
  extend T::Sig
31
14
 
@@ -66,7 +49,7 @@ class Quarantine
66
49
 
67
50
  # Scans the test_statuses from the database and store their IDs in quarantined_ids
68
51
  sig { void }
69
- def fetch_test_statuses
52
+ def on_start
70
53
  begin
71
54
  test_statuses = database.fetch_items(@options[:test_statuses_table_name])
72
55
  rescue Quarantine::DatabaseError => e
@@ -103,24 +86,40 @@ class Quarantine
103
86
  end
104
87
 
105
88
  sig { void }
106
- def upload_tests
107
- return if @tests.empty? || @tests.values.count { |test| test.status == :quarantined } >= @options[:failsafe_limit]
89
+ def on_complete
90
+ quarantined_tests = @tests.values.select { |test| test.status == :quarantined }.sort_by(&:id)
108
91
 
109
- begin
110
- timestamp = Time.now.to_i / 1000 # Truncated millisecond from timestamp for reasons specific to Flexport
111
- database.write_items(
112
- @options[:test_statuses_table_name],
113
- @tests.values.map { |item| item.to_hash.merge('updated_at' => timestamp) }
114
- )
115
- rescue Quarantine::DatabaseError => e
116
- @database_failures << "#{e.cause&.class}: #{e.cause&.message}"
92
+ if !@options[:record_tests]
93
+ log('Recording tests disabled; skipping')
94
+ elsif @tests.empty?
95
+ log('No tests found; skipping recording')
96
+ elsif quarantined_tests.count { |test| old_tests[test.id]&.status != :quarantined } >= @options[:failsafe_limit]
97
+ log('Number of quarantined tests above failsafe limit; skipping recording')
98
+ else
99
+ begin
100
+ timestamp = Time.now.to_i / 1000 # Truncated millisecond from timestamp for reasons specific to Flexport
101
+ database.write_items(
102
+ @options[:test_statuses_table_name],
103
+ @tests.values.map { |item| item.to_hash.merge('updated_at' => timestamp) }
104
+ )
105
+ rescue Quarantine::DatabaseError => e
106
+ @database_failures << "#{e.cause&.class}: #{e.cause&.message}"
107
+ end
117
108
  end
109
+
110
+ log(<<~MESSAGE)
111
+ \n[quarantine] Quarantined tests:
112
+ #{quarantined_tests.map { |test| "#{test.id} #{test.full_description}" }.join("\n ")}
113
+
114
+ [quarantine] Database errors:
115
+ #{@database_failures.join("\n ")}
116
+ MESSAGE
118
117
  end
119
118
 
120
119
  # Param: RSpec::Core::Example
121
120
  # Add the example to the internal tests list
122
121
  sig { params(example: T.untyped, status: Symbol, passed: T::Boolean).void }
123
- def record_test(example, status, passed:)
122
+ def on_test(example, status, passed:)
124
123
  extra_attributes = @options[:extra_attributes] ? @options[:extra_attributes].call(example) : {}
125
124
 
126
125
  new_consecutive_passes = passed ? (@old_tests[example.id]&.consecutive_passes || 0) + 1 : 0
@@ -145,15 +144,8 @@ class Quarantine
145
144
  @old_tests[example.id]&.status == :quarantined
146
145
  end
147
146
 
148
- sig { returns(String) }
149
- def summary
150
- quarantined_tests = @tests.values.select { |test| test.status == :quarantined }.sort_by(&:id)
151
- <<~MESSAGE
152
- \n[quarantine] Quarantined tests:
153
- #{quarantined_tests.map { |test| "#{test.id} #{test.full_description}" }.join("\n ")}
154
-
155
- [quarantine] Database errors:
156
- #{@database_failures.join("\n ")}
157
- MESSAGE
147
+ sig { params(message: String).void }
148
+ def log(message)
149
+ @options[:log].call(message) if @options[:logging]
158
150
  end
159
151
  end
@@ -21,7 +21,7 @@ class Quarantine
21
21
  sig { override.params(table_name: String).returns(T::Enumerable[Item]) }
22
22
  def fetch_items(table_name)
23
23
  parse_rows(spreadsheet.worksheet_by_title(table_name))
24
- rescue GoogleDrive::Error
24
+ rescue GoogleDrive::Error, Google::Apis::Error
25
25
  raise Quarantine::DatabaseError
26
26
  end
27
27
 
@@ -41,7 +41,12 @@ class Quarantine
41
41
  indexes = Hash[parsed_rows.each_with_index.map { |item, idx| [item['id'], idx] }]
42
42
 
43
43
  items.each do |item|
44
- cells = headers.map { |header| item[header].to_s }
44
+ cells = headers.map do |header|
45
+ match = header.match(/^(extra_)?(.+)/)
46
+ extra, name = match[1..2]
47
+ value = extra ? item['extra_attributes'][name] : item[name]
48
+ value.to_s
49
+ end
45
50
  row_idx = indexes[item['id']]
46
51
  if row_idx
47
52
  # Overwrite existing row
@@ -56,7 +61,7 @@ class Quarantine
56
61
  # Insert any items whose IDs weren't found in existing rows at the end
57
62
  worksheet.insert_rows(parsed_rows.count + 2, new_rows)
58
63
  worksheet.save
59
- rescue GoogleDrive::Error
64
+ rescue GoogleDrive::Error, Google::Apis::Error
60
65
  raise Quarantine::DatabaseError
61
66
  end
62
67
 
@@ -103,10 +108,12 @@ class Quarantine
103
108
  rows.map do |row|
104
109
  hash_row = Hash[headers.zip(row)]
105
110
  # TODO: use Google Sheets developer metadata to store type information
106
- unless hash_row['id'].empty?
107
- hash_row['extra_attributes'] = JSON.parse(hash_row['extra_attributes']) if hash_row['extra_attributes']
108
- hash_row
109
- end
111
+ next nil if hash_row['id'].empty?
112
+
113
+ extra_values, base_values = hash_row.partition { |k, _v| k.start_with?('extra_') }
114
+ base_hash = Hash[base_values]
115
+ base_hash['extra_attributes'] = Hash[extra_values]
116
+ base_hash
110
117
  end.compact
111
118
  end
112
119
  end
@@ -1,19 +1,32 @@
1
1
  # typed: strict
2
2
 
3
+ module RSpec
4
+ module Core
5
+ class Example
6
+ extend T::Sig
7
+
8
+ # The implementation of clear_exception in rspec-retry doesn't work
9
+ # for examples that use `it_behaves_like`, so we implement our own version that
10
+ # clear the exception field recursively.
11
+ sig { void }
12
+ def clear_exception!
13
+ @exception = T.let(nil, T.untyped)
14
+ T.unsafe(self).example.clear_exception! if defined?(example)
15
+ end
16
+ end
17
+ end
18
+ end
19
+
3
20
  class Quarantine
4
21
  module RSpecAdapter
5
22
  extend T::Sig
6
23
 
7
- # Purpose: create an instance of Quarantine which contains information
8
- # about the test suite (ie. quarantined tests) and binds RSpec configurations
9
- # and hooks onto the global RSpec class
10
24
  sig { void }
11
25
  def self.bind
12
- bind_rspec_configurations
13
- bind_fetch_test_statuses
14
- bind_record_tests
15
- bind_upload_tests
16
- bind_logger
26
+ register_rspec_configurations
27
+ bind_on_start
28
+ bind_on_test
29
+ bind_on_complete
17
30
  end
18
31
 
19
32
  sig { returns(Quarantine) }
@@ -25,12 +38,15 @@ class Quarantine
25
38
  extra_attributes: RSpec.configuration.quarantine_extra_attributes,
26
39
  failsafe_limit: RSpec.configuration.quarantine_failsafe_limit,
27
40
  release_at_consecutive_passes: RSpec.configuration.quarantine_release_at_consecutive_passes,
41
+ logging: RSpec.configuration.quarantine_logging,
42
+ log: method(:log),
43
+ record_tests: RSpec.configuration.quarantine_record_tests
28
44
  )
29
45
  end
30
46
 
31
47
  # Purpose: binds rspec configuration variables
32
48
  sig { void }
33
- def self.bind_rspec_configurations
49
+ def self.register_rspec_configurations
34
50
  ::RSpec.configure do |config|
35
51
  config.add_setting(:quarantine_database, default: { type: :dynamodb, region: 'us-west-1' })
36
52
  config.add_setting(:quarantine_test_statuses, { default: 'test_statuses' })
@@ -45,65 +61,72 @@ class Quarantine
45
61
 
46
62
  # Purpose: binds quarantine to fetch the test_statuses from dynamodb in the before suite
47
63
  sig { void }
48
- def self.bind_fetch_test_statuses
64
+ def self.bind_on_start
49
65
  ::RSpec.configure do |config|
50
66
  config.before(:suite) do
51
- Quarantine::RSpecAdapter.quarantine.fetch_test_statuses
67
+ Quarantine::RSpecAdapter.quarantine.on_start
52
68
  end
53
69
  end
54
70
  end
55
71
 
56
- # Purpose: binds quarantine to record test statuses
57
- sig { void }
58
- def self.bind_record_tests
59
- ::RSpec.configure do |config|
60
- config.after(:each) do |example|
61
- metadata = example.metadata
72
+ sig { params(example: RSpec::Core::Example).returns(T.nilable([Symbol, T::Boolean])) }
73
+ def self.final_status(example)
74
+ metadata = example.metadata
75
+
76
+ # The user may define their own after hook that marks an example as flaky in its metadata.
77
+ previously_quarantined = Quarantine::RSpecAdapter.quarantine.test_quarantined?(example) || metadata[:flaky]
62
78
 
63
- # optionally, the upstream RSpec configuration could define an after hook that marks an example as flaky in
64
- # the example's metadata
65
- quarantined = Quarantine::RSpecAdapter.quarantine.test_quarantined?(example) || metadata[:flaky]
66
- if example.exception
67
- if metadata[:retry_attempts] + 1 == metadata[:retry]
68
- # will record the failed test if it's final retry from the rspec-retry gem
69
- if RSpec.configuration.skip_quarantined_tests && quarantined
70
- example.clear_exception!
71
- Quarantine::RSpecAdapter.quarantine.record_test(example, :quarantined, passed: false)
72
- else
73
- Quarantine::RSpecAdapter.quarantine.record_test(example, :failing, passed: false)
74
- end
75
- end
76
- elsif metadata[:retry_attempts] > 0
77
- # will record the flaky test if it failed the first run but passed a subsequent run
78
- Quarantine::RSpecAdapter.quarantine.record_test(example, :quarantined, passed: false)
79
- elsif quarantined
80
- Quarantine::RSpecAdapter.quarantine.record_test(example, :quarantined, passed: true)
79
+ if example.exception
80
+ # The example failed _this try_.
81
+ if metadata[:retry_attempts] + 1 == metadata[:retry]
82
+ # The example failed all its retries - if it's already quarantined, keep it that way;
83
+ # otherwise, mark it as failing.
84
+ if RSpec.configuration.skip_quarantined_tests && previously_quarantined
85
+ return [:quarantined, false]
81
86
  else
82
- Quarantine::RSpecAdapter.quarantine.record_test(example, :passing, passed: true)
87
+ return [:failing, false]
83
88
  end
84
89
  end
90
+ # The example failed, but it's not the final retry yet, so return nil.
91
+ return nil # rubocop:disable Style/RedundantReturn
92
+ elsif metadata[:retry_attempts] > 0
93
+ # The example passed this time, but failed one or more times before - the definition of a flaky test.
94
+ return [:quarantined, false] # rubocop:disable Style/RedundantReturn
95
+ elsif previously_quarantined
96
+ # The example passed the first time, but it's already marked quarantined, so keep it that way.
97
+ return [:quarantined, true] # rubocop:disable Style/RedundantReturn
98
+ else
99
+ return [:passing, true] # rubocop:disable Style/RedundantReturn
85
100
  end
86
101
  end
87
102
 
103
+ # Purpose: binds quarantine to record test statuses
88
104
  sig { void }
89
- def self.bind_upload_tests
105
+ def self.bind_on_test
90
106
  ::RSpec.configure do |config|
91
- config.after(:suite) do
92
- Quarantine::RSpecAdapter.quarantine.upload_tests if RSpec.configuration.quarantine_record_tests
107
+ config.after(:each) do |example|
108
+ result = Quarantine::RSpecAdapter.final_status(example)
109
+ if result
110
+ status, passed = result
111
+ example.clear_exception! if status == :quarantined && !passed
112
+ Quarantine::RSpecAdapter.quarantine.on_test(example, status, passed: passed)
113
+ end
93
114
  end
94
115
  end
95
116
  end
96
117
 
97
- # Purpose: binds quarantine logger to output test to RSpec formatter messages
98
118
  sig { void }
99
- def self.bind_logger
119
+ def self.bind_on_complete
100
120
  ::RSpec.configure do |config|
101
121
  config.after(:suite) do
102
- if RSpec.configuration.quarantine_logging
103
- RSpec.configuration.reporter.message(Quarantine::RSpecAdapter.quarantine.summary)
104
- end
122
+ Quarantine::RSpecAdapter.quarantine.on_complete
105
123
  end
106
124
  end
107
125
  end
126
+
127
+ sig { params(message: String).void }
128
+ def self.log(message)
129
+ RSpec.configuration.reporter.message(message)
130
+ end
108
131
  end
109
132
  end
@@ -1,3 +1,3 @@
1
1
  class Quarantine
2
- VERSION = '2.1.2'.freeze
2
+ VERSION = '2.2.2'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quarantine
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.2
4
+ version: 2.2.2
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: 2021-04-14 00:00:00.000000000 Z
11
+ date: 2021-08-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -92,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
92
92
  - !ruby/object:Gem::Version
93
93
  version: '0'
94
94
  requirements: []
95
- rubygems_version: 3.0.8
95
+ rubygems_version: 3.0.2
96
96
  signing_key:
97
97
  specification_version: 4
98
98
  summary: Quarantine flaky RSpec tests