quarantine 2.1.2 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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