quarantine 2.1.0 → 2.2.1

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: ffb721a4514eda413ea4c096913b5d0c3a672b23fe6aefee44393062f202e453
4
- data.tar.gz: eec03b0ec1533643ae86245e76a69b51fdbf871e6e621293fa542b1b252fc123
3
+ metadata.gz: 31ffed67bd1774918fd52c8bfe309398d43b8c218175f2a414725382df680ee7
4
+ data.tar.gz: aca3eab589422f555224ce2638e979aff7f6fc2f17d4b33ac91e2f0177d4fb9b
5
5
  SHA512:
6
- metadata.gz: 67f36af412a0f1b23aaa018402e81b087f9595d3257726aac3b4e6472f232a498be52d683fd6b0216f652ad3f2b702d6a5f582de70a62a8fb25fc29ec5a21df1
7
- data.tar.gz: 3aabf31b49b00cded2af25bac1b8d9f3c1c1bb89a202689a87ded5e23369fb6e4d32b82f664d3c4d3b1f314cae02f34dc80ca0a0380e6c034466e7ae8ec15300
6
+ metadata.gz: '0894a8c2561b741d3f9466d28e8424cde69ece0b7f228d700b485f748eb26531bd81ac549493a91641010ceb6b34eb1d7abbaf49475f4874d6f67db297d522ec'
7
+ data.tar.gz: 281ba613e8806b8d3f1e4318a65492435d971e0e89138cb87c44de2c7709a983672930e00cdfddb46bf245da4aa2e52804b2cf47d908fe9f0dd62eae3e7ac5fd
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,6 +21,8 @@ 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, Google::Apis::Error
25
+ raise Quarantine::DatabaseError
24
26
  end
25
27
 
26
28
  sig do
@@ -35,10 +37,16 @@ class Quarantine
35
37
  new_rows = []
36
38
 
37
39
  # Map existing ID to row index
38
- indexes = Hash[parse_rows(worksheet).each_with_index.map { |item, idx| [item['id'], idx] }]
40
+ parsed_rows = parse_rows(worksheet)
41
+ indexes = Hash[parsed_rows.each_with_index.map { |item, idx| [item['id'], idx] }]
39
42
 
40
43
  items.each do |item|
41
- 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
42
50
  row_idx = indexes[item['id']]
43
51
  if row_idx
44
52
  # Overwrite existing row
@@ -51,8 +59,10 @@ class Quarantine
51
59
  end
52
60
 
53
61
  # Insert any items whose IDs weren't found in existing rows at the end
54
- worksheet.insert_rows(worksheet.rows.count + 1, new_rows)
62
+ worksheet.insert_rows(parsed_rows.count + 2, new_rows)
55
63
  worksheet.save
64
+ rescue GoogleDrive::Error, Google::Apis::Error
65
+ raise Quarantine::DatabaseError
56
66
  end
57
67
 
58
68
  private
@@ -98,9 +108,13 @@ class Quarantine
98
108
  rows.map do |row|
99
109
  hash_row = Hash[headers.zip(row)]
100
110
  # TODO: use Google Sheets developer metadata to store type information
101
- hash_row['extra_attributes'] = JSON.parse(hash_row['extra_attributes']) if hash_row['extra_attributes']
102
- hash_row
103
- 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
117
+ end.compact
104
118
  end
105
119
  end
106
120
  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) }
@@ -23,13 +36,17 @@ class Quarantine
23
36
  database: RSpec.configuration.quarantine_database,
24
37
  test_statuses_table_name: RSpec.configuration.quarantine_test_statuses,
25
38
  extra_attributes: RSpec.configuration.quarantine_extra_attributes,
26
- failsafe_limit: RSpec.configuration.quarantine_failsafe_limit
39
+ failsafe_limit: RSpec.configuration.quarantine_failsafe_limit,
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
27
44
  )
28
45
  end
29
46
 
30
47
  # Purpose: binds rspec configuration variables
31
48
  sig { void }
32
- def self.bind_rspec_configurations
49
+ def self.register_rspec_configurations
33
50
  ::RSpec.configure do |config|
34
51
  config.add_setting(:quarantine_database, default: { type: :dynamodb, region: 'us-west-1' })
35
52
  config.add_setting(:quarantine_test_statuses, { default: 'test_statuses' })
@@ -44,65 +61,72 @@ class Quarantine
44
61
 
45
62
  # Purpose: binds quarantine to fetch the test_statuses from dynamodb in the before suite
46
63
  sig { void }
47
- def self.bind_fetch_test_statuses
64
+ def self.bind_on_start
48
65
  ::RSpec.configure do |config|
49
66
  config.before(:suite) do
50
- Quarantine::RSpecAdapter.quarantine.fetch_test_statuses
67
+ Quarantine::RSpecAdapter.quarantine.on_start
51
68
  end
52
69
  end
53
70
  end
54
71
 
55
- # Purpose: binds quarantine to record test statuses
56
- sig { void }
57
- def self.bind_record_tests
58
- ::RSpec.configure do |config|
59
- config.after(:each) do |example|
60
- 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]
61
78
 
62
- # optionally, the upstream RSpec configuration could define an after hook that marks an example as flaky in
63
- # the example's metadata
64
- quarantined = Quarantine::RSpecAdapter.quarantine.test_quarantined?(example) || metadata[:flaky]
65
- if example.exception
66
- if metadata[:retry_attempts] + 1 == metadata[:retry]
67
- # will record the failed test if it's final retry from the rspec-retry gem
68
- if RSpec.configuration.skip_quarantined_tests && quarantined
69
- example.clear_exception!
70
- Quarantine::RSpecAdapter.quarantine.record_test(example, :quarantined, passed: false)
71
- else
72
- Quarantine::RSpecAdapter.quarantine.record_test(example, :failing, passed: false)
73
- end
74
- end
75
- elsif metadata[:retry_attempts] > 0
76
- # will record the flaky test if it failed the first run but passed a subsequent run
77
- Quarantine::RSpecAdapter.quarantine.record_test(example, :quarantined, passed: false)
78
- elsif quarantined
79
- 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]
80
86
  else
81
- Quarantine::RSpecAdapter.quarantine.record_test(example, :passing, passed: true)
87
+ return [:failing, false]
82
88
  end
83
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
84
100
  end
85
101
  end
86
102
 
103
+ # Purpose: binds quarantine to record test statuses
87
104
  sig { void }
88
- def self.bind_upload_tests
105
+ def self.bind_on_test
89
106
  ::RSpec.configure do |config|
90
- config.after(:suite) do
91
- 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
92
114
  end
93
115
  end
94
116
  end
95
117
 
96
- # Purpose: binds quarantine logger to output test to RSpec formatter messages
97
118
  sig { void }
98
- def self.bind_logger
119
+ def self.bind_on_complete
99
120
  ::RSpec.configure do |config|
100
121
  config.after(:suite) do
101
- if RSpec.configuration.quarantine_logging
102
- RSpec.configuration.reporter.message(Quarantine::RSpecAdapter.quarantine.summary)
103
- end
122
+ Quarantine::RSpecAdapter.quarantine.on_complete
104
123
  end
105
124
  end
106
125
  end
126
+
127
+ sig { params(message: String).void }
128
+ def self.log(message)
129
+ RSpec.configuration.reporter.message(message)
130
+ end
107
131
  end
108
132
  end
@@ -1,3 +1,3 @@
1
1
  class Quarantine
2
- VERSION = '2.1.0'.freeze
2
+ VERSION = '2.2.1'.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.0
4
+ version: 2.2.1
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-04-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec