deflake 0.1.0

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.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ module Leaks
5
+ # ActiveSupport::CurrentAttributes left set, or with a default changed in place
6
+ class Current
7
+ # Attributes left set, and a print of every default that is not a proc
8
+ State = Struct.new(:attributes, :defaults)
9
+
10
+ def snapshot
11
+ return unless defined?(::ActiveSupport) && Leaks.loaded?(::ActiveSupport, :CurrentAttributes)
12
+
13
+ classes = ::ActiveSupport::CurrentAttributes.descendants.select(&:name)
14
+ attributes = classes.filter_map do |klass|
15
+ instance = existing_instance(klass)
16
+ [klass.name, changed_attributes(klass, instance)] if instance
17
+ end
18
+ State.new(attributes.to_h, classes.to_h { |klass| [klass.name, defaults(klass)] })
19
+ end
20
+
21
+ def diff(before, after)
22
+ set(before.attributes, after.attributes) + changed_defaults(before.defaults, after.defaults)
23
+ end
24
+
25
+ private
26
+
27
+ def set(before, after)
28
+ after.flat_map do |klass, attributes|
29
+ attributes.filter_map do |name, value|
30
+ next if NilClass === value # rubocop:disable Style/CaseEquality
31
+ next if before.fetch(klass, {}).key?(name) && same?(before[klass][name], value)
32
+
33
+ "#{klass}.#{name} is still set"
34
+ end
35
+ end
36
+ end
37
+
38
+ # ActiveSupport copies a default only one level deep, so a test that changes a list inside
39
+ # one changes the default of every later test
40
+ def changed_defaults(before, after)
41
+ after.flat_map do |klass, defaults|
42
+ defaults.filter_map do |name, print|
43
+ was = before.fetch(klass, {})[name]
44
+ "#{klass}.#{name}'s default was changed in place" if was && print && was != print
45
+ end
46
+ end
47
+ end
48
+
49
+ # Asking the class for its attributes would create the instance and run the
50
+ # proc defaults before the test, which may travel in time first. A class
51
+ # without an instance has nothing set
52
+ def existing_instance(klass)
53
+ return unless klass.respond_to?(:current_instances, true)
54
+
55
+ key = klass.respond_to?(:current_instances_key, true) ? klass.send(:current_instances_key) : klass.name
56
+ klass.send(:current_instances)[key]
57
+ end
58
+
59
+ # Attributes that differ from their defaults. A default given as a proc gives a new value
60
+ # after every reset, so such attributes are not compared. A value may be any object, even a
61
+ # mock without Kernel's methods
62
+ def changed_attributes(klass, instance)
63
+ defaults = klass.respond_to?(:defaults) ? klass.defaults : {}
64
+ instance.attributes.reject do |name, value|
65
+ default = defaults[name]
66
+ Proc === default || NilClass === value || same?(value, default) # rubocop:disable Style/CaseEquality
67
+ end
68
+ end
69
+
70
+ def defaults(klass)
71
+ return {} unless klass.respond_to?(:defaults)
72
+
73
+ klass.defaults.reject { |_, default| Proc === default }.transform_values do |default| # rubocop:disable Style/CaseEquality
74
+ Marshal.dump(default).hash
75
+ rescue StandardError
76
+ nil
77
+ end
78
+ end
79
+
80
+ def same?(one, other)
81
+ one.__id__ == other.__id__ || one == other
82
+ rescue StandardError
83
+ false
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ module Leaks
5
+ # Environment variables set, changed or removed
6
+ class Env
7
+ def snapshot
8
+ ENV.to_h
9
+ end
10
+
11
+ def diff(before, after)
12
+ (before.keys | after.keys).sort.filter_map do |key|
13
+ next if before[key] == after[key]
14
+
15
+ change = if !after.key?(key) then "was removed"
16
+ elsif before.key?(key) then "was changed"
17
+ else "was set"
18
+ end
19
+ "ENV[#{key.inspect}] #{change}"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ module Leaks
5
+ # Global variables ($name) assigned or changed in place
6
+ class Globals
7
+ # Ruby itself changes these all the time, including the English names of $?, $., $_ and friends
8
+ IGNORED = %i[
9
+ $LOADED_FEATURES $LOAD_PATH $PROGRAM_NAME $FILENAME $DEBUG $VERBOSE $stdin $_
10
+ $ERROR_INFO $ERROR_POSITION $FS $FIELD_SEPARATOR $OFS $OUTPUT_FIELD_SEPARATOR
11
+ $RS $INPUT_RECORD_SEPARATOR $ORS $OUTPUT_RECORD_SEPARATOR $INPUT_LINE_NUMBER $NR
12
+ $LAST_READ_LINE $DEFAULT_OUTPUT $DEFAULT_INPUT $PID $PROCESS_ID $CHILD_STATUS
13
+ $LAST_MATCH_INFO $IGNORECASE $ARGV $MATCH $PREMATCH $POSTMATCH $LAST_PAREN_MATCH
14
+ ].freeze
15
+ # How deep fingerprint walks into containers
16
+ DEPTH = 32
17
+
18
+ # An object Marshal could dump (digest) or not (nil), known by its identity too
19
+ Leaf = Struct.new(:id, :digest)
20
+ # A container, part by part, and its digest when Marshal could dump it whole. In a Hash or a
21
+ # Set the order of the parts does not matter
22
+ Box = Struct.new(:id, :type, :unordered, :parts, :digest)
23
+
24
+ # Ruby creates a global as nil the first time code reads it, so nil counts as unset.
25
+ # The value may be any object, even one without Kernel's methods, such as a mock
26
+ def snapshot
27
+ names = global_variables.grep(/\A\$[A-Za-z_]\w*\z/) - IGNORED
28
+ names.filter_map do |name|
29
+ value = TOPLEVEL_BINDING.eval(name.to_s)
30
+ [name, safely(value)] unless NilClass === value # rubocop:disable Style/CaseEquality
31
+ end.to_h
32
+ end
33
+
34
+ def diff(before, after)
35
+ (before.keys | after.keys).sort.filter_map do |name|
36
+ next if before.key?(name) && after.key?(name) && same?(before[name], after[name])
37
+
38
+ before.key?(name) ? "#{name} was changed" : "#{name} was set"
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def safely(value)
45
+ fingerprint(value)
46
+ rescue StandardError, ScriptError, SystemStackError
47
+ Leaf.new(value.__id__, nil)
48
+ end
49
+
50
+ # The contents, so a change inside any object counts, not only inside strings, arrays and
51
+ # hashes. Containers are walked part by part, down to DEPTH, so a proc added to an array
52
+ # counts as a change. Any other object is dumped with Marshal, and one Marshal cannot dump
53
+ # (a Mutex, a proc, an IO) counts by identity: what it keeps inside, such as a lazily opened
54
+ # connection, is its own business. So does one Marshal could dump before the test and not
55
+ # after, or the other way round: it may have set up something inside on first use
56
+ def fingerprint(value)
57
+ walk(value, {}.compare_by_identity, 0)
58
+ end
59
+
60
+ def walk(value, seen, depth)
61
+ # Deeper parts and containers met before count by identity: walking them again would never end
62
+ return Leaf.new(value.__id__, nil) if depth > DEPTH || seen.key?(value)
63
+
64
+ parts = parts(value)
65
+ return Leaf.new(value.__id__, dump_or_nil(value)) unless parts
66
+
67
+ seen[value] = true
68
+ box(value, parts.map { |part| walk(part, seen, depth + 1) }, depth.zero?)
69
+ end
70
+
71
+ # Only the whole value gets a digest: it makes comparing an unchanged value quick
72
+ def box(value, parts, whole)
73
+ Box.new(value.__id__, type(value), unordered?(value), parts, whole ? dump_or_nil(value) : nil)
74
+ end
75
+
76
+ # A Hash also by its default and by how it compares keys
77
+ def type(value)
78
+ return value.class unless Hash === value # rubocop:disable Style/CaseEquality
79
+
80
+ [value.class, value.compare_by_identity?, dump_or_nil(value.default), !value.default_proc.nil?]
81
+ end
82
+
83
+ def dump_or_nil(value)
84
+ Marshal.dump(value).hash
85
+ rescue StandardError, SystemStackError
86
+ nil
87
+ end
88
+
89
+ def same?(before, after)
90
+ return before.id == after.id unless before.instance_of?(after.class)
91
+ return leaf_same?(before, after) if before.is_a?(Leaf)
92
+ return false unless before.type == after.type && before.parts.size == after.parts.size
93
+
94
+ box_same?(before, after)
95
+ end
96
+
97
+ def leaf_same?(before, after)
98
+ before.digest && after.digest ? before.digest == after.digest : before.id == after.id
99
+ end
100
+
101
+ def box_same?(before, after)
102
+ return true if before.digest && before.digest == after.digest
103
+ return before.parts.zip(after.parts).all? { |one, other| same?(one, other) } unless before.unordered
104
+
105
+ same_in_any_order?(*unmatched(before.parts, after.parts))
106
+ end
107
+
108
+ def same_in_any_order?(left, right)
109
+ left.all? do |part|
110
+ index = right.index { |other| same?(part, other) }
111
+ index && right.delete_at(index)
112
+ end
113
+ end
114
+
115
+ # The parts of a Hash or a Set that have no equal part on the other side, found by their
116
+ # digests first: comparing every part with every other would take long in a big Hash
117
+ def unmatched(before, after)
118
+ counts = before.map { |part| signature(part) }.tally
119
+ right = after.reject { |part| take(counts, signature(part)) }
120
+ [before.select { |part| take(counts, signature(part)) }, right]
121
+ end
122
+
123
+ # Takes one of the parts with this signature, if any is left
124
+ def take(counts, key)
125
+ counts[key].to_i.positive? && (counts[key] -= 1)
126
+ end
127
+
128
+ # Equal parts have equal signatures. An object Marshal cannot dump has its identity instead
129
+ def signature(part)
130
+ return part.digest || [:id, part.id].hash if part.is_a?(Leaf)
131
+
132
+ parts = part.parts.map { |inner| signature(inner) }
133
+ [part.type, part.unordered ? parts.sort : parts].hash
134
+ end
135
+
136
+ def unordered?(value)
137
+ Hash === value || (defined?(::Set) && ::Set === value) # rubocop:disable Style/CaseEquality
138
+ end
139
+
140
+ # The parts of a container, nil for anything else. A Hash gives its entries, as pairs.
141
+ # Set and StringIO may not be loaded
142
+ def parts(value)
143
+ case value
144
+ when Array, Struct, Hash then value.to_a
145
+ when Thread::Queue then [value.size, value.closed?]
146
+ else loaded_parts(value)
147
+ end
148
+ end
149
+
150
+ def loaded_parts(value)
151
+ if defined?(::Set) && ::Set === value then value.to_a # rubocop:disable Style/CaseEquality
152
+ elsif defined?(::StringIO) && ::StringIO === value then [value.string, value.pos] # rubocop:disable Style/CaseEquality
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ module Leaks
5
+ # Jobs left in the ActiveJob test queue
6
+ class Jobs
7
+ # Ids of the enqueued jobs: ActiveJob::TestHelper empties the queue before
8
+ # every test, so comparing sizes would miss a job left after another one
9
+ def snapshot
10
+ return unless defined?(::ActiveJob) && Leaks.loaded?(::ActiveJob, :Base)
11
+
12
+ # Not queue_adapter: when no adapter is set, it sets :async, which would
13
+ # stop ActiveJob::TestHelper from installing its test adapter
14
+ adapter = ::ActiveJob::Base._queue_adapter
15
+ adapter.enqueued_jobs.map(&:object_id) if adapter.respond_to?(:enqueued_jobs)
16
+ end
17
+
18
+ def diff(before, after)
19
+ added = (after - before).size
20
+ return [] unless added.positive?
21
+
22
+ ["#{added} enqueued #{added == 1 ? "job" : "jobs"} left in the ActiveJob queue"]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ module Leaks
5
+ # Database rows created or deleted outside a rolled back transaction, in every connected database
6
+ class Rows
7
+ INTERNAL_TABLES = %w[schema_migrations ar_internal_metadata].freeze
8
+ MAIN = "ActiveRecord::Base"
9
+
10
+ # Row counts per model class that owns a connection, then per table
11
+ # ({ "ActiveRecord::Base" => { "users" => 2 } }), and how many times fixtures were loaded so far
12
+ State = Struct.new(:counts, :loads)
13
+
14
+ # The first test of a process loads fixtures in its setup, and those rows
15
+ # are not its leak, so the counts right after the load are its baseline.
16
+ # The tables a load wrote to are those of its fixture sets and any other
17
+ # whose count it changed, such as the join table of has_and_belongs_to_many.
18
+ # A load inside a transaction the test opened, which rolls back, is no baseline. One inside
19
+ # a transaction that was open before the test, as test-prof's before_all opens, is
20
+ module FixtureLoad
21
+ def create_fixtures(...)
22
+ before = Leaks.quietly { Rows.new.snapshot&.counts }
23
+ super.tap do |sets|
24
+ Leaks.quietly do
25
+ after = Rows.new.snapshot&.counts
26
+ tables = Rows.in_new_transaction? ? [] : Array(sets).map(&:table_name) | Rows.changed(before, after)
27
+ Rows.fixtures_loaded(after, tables)
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ class << self
34
+ attr_reader :loads
35
+ # Transactions open at the last snapshot, before a test
36
+ attr_accessor :depth
37
+
38
+ # tables: the tables this load wrote to, nil when not known
39
+ def fixtures_loaded(counts, tables = nil)
40
+ @loads = loads.to_i + 1
41
+ fixture_loads[@loads] = [counts, tables]
42
+ end
43
+
44
+ # Load number => [the counts right after it, the tables it wrote to]
45
+ def fixture_loads
46
+ @fixture_loads ||= {}
47
+ end
48
+
49
+ # The counts right after the last load
50
+ def after_fixtures = fixture_loads[loads]&.first
51
+
52
+ # Tables whose row count differs between two snapshots
53
+ def changed(before, after)
54
+ after.to_h.flat_map do |owner, tables|
55
+ tables.reject { |table, count| before&.dig(owner, table) == count }.keys
56
+ end
57
+ end
58
+
59
+ def in_new_transaction? = open_transactions > depth.to_i
60
+
61
+ def open_transactions = ::ActiveRecord::Base.connection_pool.with_connection(&:open_transactions)
62
+ end
63
+
64
+ def snapshot
65
+ return unless defined?(::ActiveRecord) && Leaks.loaded?(::ActiveRecord, :Base)
66
+
67
+ watch_fixtures
68
+ pools = connected_pools
69
+ return if pools.empty?
70
+
71
+ Rows.depth = Rows.open_transactions
72
+ State.new(pools.to_h { |owner, pool| [owner, count(pool)] }, Rows.loads.to_i)
73
+ end
74
+
75
+ # Only databases connected before the test are compared: the rows of one
76
+ # the test connected to first were there before it
77
+ def diff(before, after)
78
+ baselines = baselines(before, after)
79
+ changes = before.counts.flat_map do |owner, tables|
80
+ changes(owner, tables, after.counts.fetch(owner, {}), baselines[owner])
81
+ end
82
+ changes.sort.map { |table, change| message(table, change) }
83
+ end
84
+
85
+ private
86
+
87
+ # Owner => { table => rows right after the last load during the test that wrote to it }.
88
+ # A load whose tables are not known wrote to every table
89
+ def baselines(before, after)
90
+ ((before.loads + 1)..after.loads).each_with_object(Hash.new { |hash, owner| hash[owner] = {} }) do |load, all|
91
+ counts, tables = Rows.fixture_loads[load]
92
+ counts.to_h.each do |owner, rows|
93
+ all[owner].merge!(tables ? rows.slice(*tables) : rows)
94
+ end
95
+ end
96
+ end
97
+
98
+ # fixtures: rows in each table the fixtures were loaded into, right after the load
99
+ def changes(owner, before, after, fixtures)
100
+ after.filter_map do |table, now|
101
+ change = change(now, before.fetch(table, 0), fixtures&.fetch(table, nil))
102
+ [name(owner, table), change] if change
103
+ end
104
+ end
105
+
106
+ # Loading fixtures replaces whatever a fixture table held, so the change is counted from
107
+ # the load. A test without a transaction that empties the table in teardown, as such tests
108
+ # do with the fixtures they loaded, leaves nothing behind
109
+ def change(now, before, loaded)
110
+ change = now - (loaded || before)
111
+ change unless change.zero? || (loaded && now.zero?)
112
+ end
113
+
114
+ # Tables of the main database go by their names, others get the model class in front: "AnimalsRecord.dogs"
115
+ def name(owner, table)
116
+ owner == MAIN ? table : "#{owner}.#{table}"
117
+ end
118
+
119
+ def message(table, change)
120
+ rows = "#{change.abs} #{change.abs == 1 ? "row" : "rows"}"
121
+ change.positive? ? "#{table}: #{rows} left behind" : "#{table}: #{rows} deleted for good"
122
+ end
123
+
124
+ # Model class that owns the connection ("ActiveRecord::Base", "AnimalsRecord") => its pool.
125
+ # Shards other than the default one go by their name too: "ShardRecord[one]"
126
+ def connected_pools
127
+ handler = ::ActiveRecord::Base.connection_handler
128
+ writing = ::ActiveRecord.respond_to?(:writing_role) ? ::ActiveRecord.writing_role : :writing
129
+ shards = handler.connection_pool_list(writing).map(&:shard).uniq
130
+ handler.connection_pool_names.product(shards).filter_map do |owner, shard|
131
+ pool = handler.retrieve_connection_pool(owner, role: writing, shard: shard)
132
+ [shard == ::ActiveRecord::Base.default_shard ? owner : "#{owner}[#{shard}]", pool] if pool&.connected?
133
+ end
134
+ end
135
+
136
+ # Outside the query cache: a count the test makes itself, such as User.count, must still go to
137
+ # the database, and must not find the cache emptied by deflake either
138
+ def count(pool)
139
+ pool.with_connection do |connection|
140
+ uncached(connection) do
141
+ (connection.tables - INTERNAL_TABLES).to_h do |table|
142
+ [table, connection.select_value("SELECT COUNT(*) FROM #{connection.quote_table_name(table)}").to_i]
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ def uncached(connection, &)
149
+ return yield unless connection.respond_to?(:uncached)
150
+
151
+ # Rails 7.2 and newer would let the counts empty the cache of other connections
152
+ dirties = connection.method(:uncached).parameters.any? { |_, name| name == :dirties }
153
+ dirties ? connection.uncached(dirties: false, &) : connection.uncached(&)
154
+ end
155
+
156
+ # Before fixtures load for the first time, without loading them: Rails tells when they do
157
+ def watch_fixtures
158
+ if Leaks.loaded?(::ActiveRecord, :FixtureSet)
159
+ prepend_to(::ActiveRecord::FixtureSet)
160
+ elsif !@waiting && defined?(::ActiveSupport.on_load)
161
+ @waiting = true
162
+ ::ActiveSupport.on_load(:active_record_fixture_set) { Deflake::Leaks::Rows.new.send(:prepend_to, self) }
163
+ end
164
+ end
165
+
166
+ def prepend_to(fixture_set)
167
+ fixture_set.singleton_class.prepend(FixtureLoad) unless fixture_set.singleton_class.include?(FixtureLoad)
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "leaks/env"
4
+ require_relative "leaks/clock"
5
+ require_relative "leaks/globals"
6
+ require_relative "leaks/cache"
7
+ require_relative "leaks/current"
8
+ require_relative "leaks/rows"
9
+ require_relative "leaks/jobs"
10
+
11
+ module Deflake
12
+ # Global state a test changed and did not put back. Every detector has two methods:
13
+ # snapshot returns the current state (nil when it does not apply), and
14
+ # diff(before, after) returns one message per leak. Add your own in test_helper:
15
+ #
16
+ # Deflake::Leaks.detectors << MyDetector.new
17
+ module Leaks
18
+ module_function
19
+
20
+ def detectors
21
+ @detectors ||= [Env, Clock, Globals, Cache, Current, Rows, Jobs].map(&:new)
22
+ end
23
+
24
+ def snapshot
25
+ detectors.to_h { |detector| [detector, quietly { detector.snapshot }] }
26
+ end
27
+
28
+ def since(snapshot)
29
+ snapshot.flat_map do |detector, before|
30
+ next [] if before.nil?
31
+
32
+ after = quietly { detector.snapshot }
33
+ after.nil? ? [] : quietly { detector.diff(before, after) } || []
34
+ end
35
+ end
36
+
37
+ # Whether a constant is there without loading it: asking for an autoloaded class such as
38
+ # ActiveRecord::Base would load it inside a test, and run the hooks that wait for it there
39
+ def loaded?(namespace, name)
40
+ namespace.const_defined?(name, false) && !namespace.autoload?(name)
41
+ end
42
+
43
+ # Leaks of a group of tests that are not just its tests' leaks added up: "2 jobs" after two
44
+ # tests that each left "1 job" is the same leak, and so are two new cache keys after two tests
45
+ # that each left one
46
+ def own(leaks, of_tests)
47
+ kinds = of_tests.map { |leak| kind(leak) }
48
+ leaks.reject { |leak| kinds.include?(kind(leak)) }
49
+ end
50
+
51
+ # What a leak is about, without its count and the list after a colon. A number inside a name,
52
+ # as in $retry2, stays
53
+ def kind(leak)
54
+ leak.sub(/:\s.*\z/m, "").sub(/\b\d+\b.*\z/m) { |rest| rest.gsub(/\b\d+\b/, "N").gsub(/s\b/, "") }
55
+ end
56
+
57
+ # A broken detector must never break the tests it watches. NotImplementedError
58
+ # (an adapter without some SQL) is a ScriptError, and a too deep structure raises
59
+ # a SystemStackError, neither of them a StandardError
60
+ def quietly
61
+ yield
62
+ rescue StandardError, ScriptError, SystemStackError
63
+ nil
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "minitest_reporter"
4
+
5
+ if Gem::Version.new(Minitest::VERSION) < Gem::Version.new("5.11")
6
+ abort "deflake needs Minitest 5.11 or newer, this is #{Minitest::VERSION}"
7
+ end
8
+
9
+ module Minitest
10
+ # Minitest calls this at startup because the "deflake" plugin is registered
11
+ # below. Does nothing unless deflake started this process
12
+ def self.plugin_deflake_init(options)
13
+ path = ENV.delete(Deflake::ResultFile::ENV_KEY) or return
14
+
15
+ if ENV.delete(Deflake::ResultFile::LIST_KEY)
16
+ Deflake::MinitestTests.list(path)
17
+ # Only listing was asked for, so Minitest itself runs nothing.
18
+ # A string, because Minitest 5 cannot take a Regexp here
19
+ return options[:exclude] = "/./"
20
+ end
21
+
22
+ # Minitest still runs the tests itself, so class-level hooks and other plugins work as usual
23
+ ids = Deflake::MinitestTests.requested
24
+ Deflake::MinitestTests.run_only(ids) if ids
25
+ reporter << Deflake::MinitestReporter.new(path, options[:seed])
26
+ end
27
+ end
28
+
29
+ if ENV.key?(Deflake::ResultFile::ENV_KEY)
30
+ # Before the test file loads, so a test that a macro defines is found at the line that calls it
31
+ list = ENV.fetch(Deflake::ResultFile::LIST_KEY, nil)
32
+ Deflake::MinitestTests.watch_declarations(list) if list
33
+ Minitest::Runnable.singleton_class.prepend(Deflake::MinitestTests::ClassLeaks)
34
+ end
35
+
36
+ # Minitest 5 loads the plugins of other gems only while no plugin is registered, so load them first
37
+ Minitest.load_plugins if Minitest::VERSION.to_i < 6 && !ENV["MT_NO_PLUGINS"] && !ARGV.include?("--no-plugins")
38
+ Minitest.extensions << "deflake" unless Minitest.extensions.include?("deflake")
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require_relative "result_file"
5
+ require_relative "leaks"
6
+ require_relative "minitest_tests"
7
+
8
+ module Deflake
9
+ # Writes the result of every test to a file, one JSON line per test.
10
+ # Also checks every test for leaks: prerecord and record run outside the test's
11
+ # own run method, so whatever Rails does around it (the executor clearing
12
+ # Current, a rolled back transaction) has already happened.
13
+ # Tests in anonymous classes have no name to run them by, and results that are not tests (a
14
+ # class-level hook such as minitest-hooks' before_all reports its error as a result) cannot be
15
+ # run alone: their failures are written as failures outside of tests
16
+ class MinitestReporter < Minitest::AbstractReporter
17
+ class << self
18
+ # The reporter of this process, nil until Minitest starts it
19
+ attr_accessor :current
20
+ end
21
+
22
+ def initialize(path, seed)
23
+ super()
24
+ @path = path
25
+ @seed = seed
26
+ # Test id => state before it. parallelize_me! runs several tests at once
27
+ @before = {}
28
+ # Every leak the tests reported so far
29
+ @leaks = []
30
+ @lock = Mutex.new
31
+ ResultFile.start(path)
32
+ self.class.current = self
33
+ end
34
+
35
+ def prerecord(klass, name)
36
+ prefix = MinitestTests.prefix(klass) or return
37
+
38
+ id = "#{prefix}##{name}"
39
+ ResultFile.append(@path, started: id, name: id)
40
+ snapshot = Leaks.snapshot
41
+ @lock.synchronize { @before[id] = snapshot }
42
+ end
43
+
44
+ def record(result)
45
+ return record_unnamed(result, "#{result.name} (in a class without a name)") unless result.klass
46
+
47
+ id, before = @lock.synchronize { take(result) }
48
+ # Every test Minitest runs is prerecorded first
49
+ return record_unnamed(result, "#{id} (not a test)") unless before
50
+
51
+ leaks = Leaks.since(before)
52
+ @lock.synchronize { @leaks.concat(leaks) }
53
+ return ResultFile.append(@path, id: id, name: id, skipped: true, seed: @seed, leaks: leaks) if result.skipped?
54
+
55
+ ResultFile.append(@path, id: id, name: id, passed: result.passed?, seed: @seed,
56
+ error: error_message(result.failure), leaks: leaks)
57
+ end
58
+
59
+ # Leaks of a whole class that none of its tests reported, as errors outside of tests
60
+ def around_class(klass)
61
+ before = Leaks.snapshot
62
+ reported = @leaks.size
63
+ yield
64
+ ensure
65
+ leaks = Leaks.own(Leaks.since(before), @leaks.drop(reported)) if before
66
+ if leaks&.any?
67
+ label = MinitestTests.prefix(klass) || "a class without a name"
68
+ ResultFile.append(@path, outside: nil, leaks: leaks.map { |leak| "#{label}, outside its tests: #{leak}" })
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ # The id of the test this result is for, and the state before it. A result names only its
75
+ # class, so of two classes with the same name the one whose test started is meant
76
+ def take(result)
77
+ ids = MinitestTests.named(result.klass).map { |klass| "#{MinitestTests.prefix(klass)}##{result.name}" }
78
+ ids = ["#{result.klass}##{result.name}"] if ids.empty?
79
+ id = ids.find { |candidate| @before.key?(candidate) } || ids.first
80
+ [id, @before.delete(id)]
81
+ end
82
+
83
+ # Passing ones are written too: they tell that tests ran, which the order of a bisect leaves out
84
+ def record_unnamed(result, label)
85
+ return ResultFile.append(@path, unnamed: label, passed: true) if result.passed? || result.skipped?
86
+
87
+ ResultFile.append(@path, unnamed: label, error: error_message(result.failure))
88
+ end
89
+
90
+ # An unexpected exception carries its backtrace in the message, which would
91
+ # split identical errors into separate groups
92
+ def error_message(failure)
93
+ case failure
94
+ when nil then nil
95
+ when Minitest::UnexpectedError then "#{failure.error.class}: #{failure.error.message}"
96
+ else failure.message
97
+ end
98
+ end
99
+ end
100
+ end