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 +4 -4
- data/lib/quarantine.rb +31 -39
- data/lib/quarantine/databases/google_sheets.rb +20 -6
- data/lib/quarantine/rspec_adapter.rb +69 -45
- data/lib/quarantine/version.rb +1 -1
- metadata +2 -2
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 31ffed67bd1774918fd52c8bfe309398d43b8c218175f2a414725382df680ee7
         | 
| 4 | 
            +
              data.tar.gz: aca3eab589422f555224ce2638e979aff7f6fc2f17d4b33ac91e2f0177d4fb9b
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 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  | 
| 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  | 
| 107 | 
            -
                 | 
| 89 | 
            +
              def on_complete
         | 
| 90 | 
            +
                quarantined_tests = @tests.values.select { |test| test.status == :quarantined }.sort_by(&:id)
         | 
| 108 91 |  | 
| 109 | 
            -
                 | 
| 110 | 
            -
                   | 
| 111 | 
            -
             | 
| 112 | 
            -
             | 
| 113 | 
            -
             | 
| 114 | 
            -
                  )
         | 
| 115 | 
            -
                 | 
| 116 | 
            -
                   | 
| 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  | 
| 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 {  | 
| 149 | 
            -
              def  | 
| 150 | 
            -
                 | 
| 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 | 
            -
                     | 
| 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  | 
| 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( | 
| 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 | 
            -
                       | 
| 102 | 
            -
             | 
| 103 | 
            -
             | 
| 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 | 
            -
                   | 
| 13 | 
            -
                   | 
| 14 | 
            -
                   | 
| 15 | 
            -
                   | 
| 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. | 
| 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. | 
| 64 | 
            +
                def self.bind_on_start
         | 
| 48 65 | 
             
                  ::RSpec.configure do |config|
         | 
| 49 66 | 
             
                    config.before(:suite) do
         | 
| 50 | 
            -
                      Quarantine::RSpecAdapter.quarantine. | 
| 67 | 
            +
                      Quarantine::RSpecAdapter.quarantine.on_start
         | 
| 51 68 | 
             
                    end
         | 
| 52 69 | 
             
                  end
         | 
| 53 70 | 
             
                end
         | 
| 54 71 |  | 
| 55 | 
            -
                 | 
| 56 | 
            -
                 | 
| 57 | 
            -
             | 
| 58 | 
            -
             | 
| 59 | 
            -
             | 
| 60 | 
            -
             | 
| 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 | 
            -
             | 
| 63 | 
            -
             | 
| 64 | 
            -
             | 
| 65 | 
            -
                      if  | 
| 66 | 
            -
             | 
| 67 | 
            -
             | 
| 68 | 
            -
             | 
| 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 | 
            -
                         | 
| 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. | 
| 105 | 
            +
                def self.bind_on_test
         | 
| 89 106 | 
             
                  ::RSpec.configure do |config|
         | 
| 90 | 
            -
                    config.after(: | 
| 91 | 
            -
                      Quarantine::RSpecAdapter. | 
| 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. | 
| 119 | 
            +
                def self.bind_on_complete
         | 
| 99 120 | 
             
                  ::RSpec.configure do |config|
         | 
| 100 121 | 
             
                    config.after(:suite) do
         | 
| 101 | 
            -
                       | 
| 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
         | 
    
        data/lib/quarantine/version.rb
    CHANGED
    
    
    
        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 | 
| 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- | 
| 11 | 
            +
            date: 2021-04-18 00:00:00.000000000 Z
         | 
| 12 12 | 
             
            dependencies:
         | 
| 13 13 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 14 14 | 
             
              name: rspec
         |