quarantine 2.1.0 → 2.2.1

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: 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