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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +140 -0
- data/exe/deflake +15 -0
- data/lib/deflake/adapters/minitest.rb +81 -0
- data/lib/deflake/adapters/rspec.rb +107 -0
- data/lib/deflake/adapters.rb +28 -0
- data/lib/deflake/bisect.rb +114 -0
- data/lib/deflake/child.rb +63 -0
- data/lib/deflake/cli.rb +137 -0
- data/lib/deflake/culprits.rb +18 -0
- data/lib/deflake/diagnosis.rb +30 -0
- data/lib/deflake/execution.rb +173 -0
- data/lib/deflake/investigation.rb +112 -0
- data/lib/deflake/json_report.rb +41 -0
- data/lib/deflake/leaks/cache.rb +29 -0
- data/lib/deflake/leaks/clock.rb +60 -0
- data/lib/deflake/leaks/current.rb +87 -0
- data/lib/deflake/leaks/env.rb +24 -0
- data/lib/deflake/leaks/globals.rb +157 -0
- data/lib/deflake/leaks/jobs.rb +26 -0
- data/lib/deflake/leaks/rows.rb +171 -0
- data/lib/deflake/leaks.rb +66 -0
- data/lib/deflake/minitest.rb +38 -0
- data/lib/deflake/minitest_reporter.rb +100 -0
- data/lib/deflake/minitest_tests.rb +161 -0
- data/lib/deflake/outcome.rb +19 -0
- data/lib/deflake/result_file.rb +89 -0
- data/lib/deflake/rspec.rb +90 -0
- data/lib/deflake/rspec_formatter.rb +91 -0
- data/lib/deflake/run.rb +11 -0
- data/lib/deflake/runner.rb +143 -0
- data/lib/deflake/summary.rb +55 -0
- data/lib/deflake/tail.rb +67 -0
- data/lib/deflake/text_report.rb +110 -0
- data/lib/deflake/version.rb +5 -0
- data/lib/deflake.rb +26 -0
- metadata +88 -0
|
@@ -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
|