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,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require_relative "result_file"
5
+
6
+ module Deflake
7
+ # The tests of a Minitest process: which of them to run, in which order, and where each is declared.
8
+ # A test is known by "Class#test_name", the class by its name, and a second class
9
+ # with the same name, as two describe blocks with one description give, by "Class (2)"
10
+ module MinitestTests
11
+ # Checks what a whole class leaves behind, beyond the leaks of its tests: the state a
12
+ # class-level hook such as minitest-hooks' before_all sets up and does not put back
13
+ module ClassLeaks
14
+ define_method(Minitest::VERSION.to_i < 6 ? :run : :run_suite) do |reporter, options = {}|
15
+ recorder = MinitestReporter.current
16
+ # Tests that run in parallel would mix their leaks with those of other classes
17
+ order = respond_to?(:run_order) ? run_order : test_order
18
+ return super(reporter, options) unless recorder && order != :parallel
19
+
20
+ recorder.around_class(self) { super(reporter, options) }
21
+ end
22
+ end
23
+
24
+ class << self
25
+ # Test ids asked for through DEFLAKE_ORDER or DEFLAKE_ONLY, in the order to run them.
26
+ # The variables are removed, so a test that starts deflake itself does not pass them on
27
+ def requested
28
+ order = ENV.delete(ResultFile::ORDER_KEY)
29
+ only = ENV.delete(ResultFile::ONLY_KEY)
30
+ return ResultFile.unpack_ids(File.read(order)) if order
31
+
32
+ ResultFile.unpack_ids(only) if only
33
+ end
34
+
35
+ # Makes Minitest run exactly these tests in exactly this order. Minitest runs all tests of
36
+ # a class together, so the ids of a recorded order come class by class. Each class still
37
+ # seeds the random numbers as Minitest does, so code that uses rand behaves as in a whole run
38
+ def run_only(ids)
39
+ order = by_class(ids)
40
+ order.each do |klass, methods|
41
+ original = klass.method(:runnable_methods)
42
+ klass.define_singleton_method(:runnable_methods) do
43
+ original.call
44
+ methods
45
+ end
46
+ end
47
+ only_these(order.keys)
48
+ end
49
+
50
+ # Only Minitest's own run gets these classes: afterwards the tests see every class again,
51
+ # as tests of a Minitest extension that reset and list them do
52
+ def only_these(suites)
53
+ # Minitest shuffles the classes before it runs them
54
+ suites.define_singleton_method(:shuffle) { |*| self }
55
+ OnlyThese.suites = suites
56
+ Minitest::Runnable.singleton_class.prepend(OnlyThese)
57
+ end
58
+
59
+ # Gives the classes to run once, then Minitest's own list again
60
+ module OnlyThese
61
+ class << self
62
+ attr_accessor :suites
63
+ end
64
+
65
+ def runnables
66
+ suites = OnlyThese.suites
67
+ return super unless suites
68
+
69
+ OnlyThese.suites = nil
70
+ suites
71
+ end
72
+ end
73
+
74
+ # Class => its methods among these ids, in their order
75
+ def by_class(ids)
76
+ tests = {}
77
+ each_test { |klass, method_name, id| tests[id] = [klass, method_name] }
78
+ ids.filter_map { |id| tests[id] }.group_by(&:first).transform_values { |pairs| pairs.map(&:last) }
79
+ end
80
+
81
+ # Remembers where in this file each test is declared, before the file loads. A test that a
82
+ # macro or Rails' test "..." defines has its method's source elsewhere
83
+ def watch_declarations(path)
84
+ Minitest::Runnable.singleton_class.prepend(declarations_hook(path, declared))
85
+ end
86
+
87
+ def declarations_hook(path, found)
88
+ target = File.expand_path(path).b
89
+ Module.new do
90
+ define_method(:method_added) do |name|
91
+ place = name.to_s.start_with?("test_") && MinitestTests.declared_at(caller_locations, target)
92
+ found[self][name.to_s] = [path, place.lineno] if place
93
+ super(name)
94
+ end
95
+ end
96
+ end
97
+
98
+ # The line of this file that declares the test: in the body of a class or a describe block,
99
+ # not inside a helper method that defines tests, such as Rails' test. None for a test in
100
+ # another file this one requires: the line of the require does not declare it
101
+ def declared_at(locations, target)
102
+ first = locations.index { |location| in_file?(location, target) } or return
103
+ return if locations.first(first).any? { |location| location.label.to_s.include?("<top (required)>") }
104
+
105
+ here = locations.select { |location| in_file?(location, target) }
106
+ here.find { |location| location.label.to_s.include?("<") } || here.first
107
+ end
108
+
109
+ def in_file?(location, target)
110
+ file = location.absolute_path || location.path
111
+ file && File.expand_path(file).b == target
112
+ end
113
+
114
+ # Class => { test method => [file, line] }
115
+ def declared
116
+ @declared ||= Hash.new { |hash, klass| hash[klass] = {} }.compare_by_identity
117
+ end
118
+
119
+ # Writes every test with the file and line it is declared at, without running any.
120
+ # A test in a class without a name has no id
121
+ def list(path)
122
+ Minitest::Runnable.runnables.each do |klass|
123
+ prefix = prefix(klass)
124
+ klass.runnable_methods.each do |method_name|
125
+ file, line = declared[klass][method_name] || klass.instance_method(method_name).source_location
126
+ ResultFile.append(path, listed: prefix && "#{prefix}##{method_name}", file: file, line: line)
127
+ end
128
+ end
129
+ end
130
+
131
+ def each_test
132
+ Minitest::Runnable.runnables.each do |klass|
133
+ prefix = prefix(klass) or next
134
+ klass.runnable_methods.each { |method_name| yield klass, method_name, "#{prefix}##{method_name}" }
135
+ end
136
+ end
137
+
138
+ # "Class", "Class (2)" for the second class with that name, nil for a class without a name
139
+ def prefix(klass)
140
+ prefixes[klass]
141
+ end
142
+
143
+ # Classes with this name, in the order they were defined
144
+ def named(name)
145
+ prefixes.keys.select { |klass| klass.name == name }
146
+ end
147
+
148
+ private
149
+
150
+ def prefixes
151
+ @prefixes ||= begin
152
+ seen = Hash.new(0)
153
+ Minitest::Runnable.runnables.select(&:name).to_h do |klass|
154
+ count = seen[klass.name] += 1
155
+ [klass, count == 1 ? klass.name : "#{klass.name} (#{count})"]
156
+ end.compare_by_identity
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ module Execution
5
+ # What one test process left: rows (nil when it wrote none), results of tests that cannot run
6
+ # by name (unnamed), errors outside of tests it told about, its status and the end of its output
7
+ Outcome = Struct.new(:rows, :unnamed, :outside, :status, :output) do
8
+ # Failures of tests that cannot run by name, such as those in a class without a name
9
+ def unnamed_failures = unnamed.reject { |row| row["passed"] }
10
+
11
+ # Whether any test ran, even one that cannot run by name
12
+ def ran? = rows.any? || unnamed.any?
13
+
14
+ def failed_test? = rows.any? { |row| row["passed"] == false }
15
+
16
+ def leaks = outside.flat_map { |row| row["leaks"] || [] }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ # A file where a test process records one JSON line per test: a "started" line
5
+ # when the test begins and a result line when it ends. The test process writes it without
6
+ # the json library, which would add methods such as Object#to_json to the tests it runs
7
+ module ResultFile
8
+ # Name of the environment variable that tells a test process where to write
9
+ ENV_KEY = "DEFLAKE_OUTPUT"
10
+ # Path of a file with test ids to run in exactly this order instead of the whole file
11
+ ORDER_KEY = "DEFLAKE_ORDER"
12
+ # Test ids to run, like ORDER_KEY but for a few ids passed directly (Minitest)
13
+ ONLY_KEY = "DEFLAKE_ONLY"
14
+ # Path of the file whose tests to list, with where each is defined, instead of running them (Minitest)
15
+ LIST_KEY = "DEFLAKE_LIST"
16
+ # Every variable that tells a test process what to do: a test that starts deflake itself
17
+ # must not pass them on
18
+ KEYS = [ENV_KEY, ORDER_KEY, ONLY_KEY, LIST_KEY].freeze
19
+ # Characters JSON escapes with a short form; other control characters become \u00XX
20
+ ESCAPES = { '"' => '\\"', "\\" => "\\\\", "\n" => "\\n", "\r" => "\\r", "\t" => "\\t" }.freeze
21
+
22
+ module_function
23
+
24
+ def append(path, **fields)
25
+ File.open(path, "a") { |file| file.puts(generate(clean(fields))) }
26
+ end
27
+
28
+ # Creates the file, so a run where no test wrote a line still tells that the process got going
29
+ def start(path)
30
+ File.open(path, "a", &:itself)
31
+ end
32
+
33
+ # A process killed in the middle of a write leaves a broken last line, which is skipped
34
+ def read(path)
35
+ require "json"
36
+ File.readlines(path, mode: "r:UTF-8").filter_map do |line|
37
+ JSON.parse(line)
38
+ rescue JSON::ParserError
39
+ nil
40
+ end
41
+ end
42
+
43
+ # Test ids as one line of text, for an environment variable or a file
44
+ def pack_ids(ids)
45
+ [Marshal.dump(Array(ids).map { |id| clean(id) })].pack("m0")
46
+ end
47
+
48
+ def unpack_ids(text)
49
+ Marshal.load(text.unpack1("m0")) # rubocop:disable Security/MarshalLoad
50
+ end
51
+
52
+ # Error messages can carry bytes that are not valid UTF-8, which JSON refuses. A string in
53
+ # another encoding, such as Latin-1, is converted, not just relabelled
54
+ def clean(value)
55
+ case value
56
+ when String then clean_string(value)
57
+ when Array then value.map { |item| clean(item) }
58
+ when Hash then value.to_h { |key, item| [key.to_s, clean(item)] }
59
+ else value
60
+ end
61
+ end
62
+
63
+ def clean_string(value)
64
+ if value.valid_encoding? && ![Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(value.encoding)
65
+ value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
66
+ else
67
+ String.new(value, encoding: Encoding::UTF_8).scrub
68
+ end
69
+ end
70
+
71
+ # JSON of strings, numbers, true, false, nil, arrays and hashes, which is all a results line holds
72
+ def generate(value)
73
+ case value
74
+ when Array then "[#{value.map { |item| generate(item) }.join(",")}]"
75
+ when Hash then "{#{value.map { |key, item| "#{quote(key.to_s)}:#{generate(item)}" }.join(",")}}"
76
+ when nil, true, false, Integer, Float then scalar(value)
77
+ else quote(value.to_s)
78
+ end
79
+ end
80
+
81
+ def scalar(value)
82
+ value.nil? || (value.is_a?(Float) && !value.finite?) ? "null" : value.to_s
83
+ end
84
+
85
+ def quote(text)
86
+ "\"#{text.gsub(/["\\\x00-\x1f]/) { |char| ESCAPES[char] || format("\\u%04x", char.ord) }}\""
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require_relative "rspec_formatter"
5
+ require_relative "leaks"
6
+
7
+ if Gem::Version.new(RSpec::Core::Version::STRING) < Gem::Version.new("3.3")
8
+ abort "deflake needs RSpec 3.3 or newer, this is #{RSpec::Core::Version::STRING}"
9
+ end
10
+
11
+ module Deflake
12
+ # What deflake changes in RSpec inside a test process it started
13
+ module RSpecRun
14
+ # deflake runs every example: a run stopped at the first failure would hide the others,
15
+ # whether the project asks for it in .rspec or in spec_helper.rb. Nor is the profile printed,
16
+ # which would fill the end of the output where errors outside of examples are looked for
17
+ module Settings
18
+ # rubocop:disable Naming/PredicateMethod -- RSpec's own names
19
+ def fail_fast = false
20
+ def profile_examples = false
21
+ def profile_examples? = false
22
+ # rubocop:enable Naming/PredicateMethod
23
+ end
24
+
25
+ # Errors outside of examples, such as in an after(:context) hook, go to deflake's results
26
+ # whatever the formatters print around them
27
+ module Reporter
28
+ def notify_non_example_exception(exception, context_description)
29
+ RSpecFormatter.outside(context_description, exception)
30
+ super
31
+ end
32
+ end
33
+
34
+ module_function
35
+
36
+ # The examples deflake asked for through a file, which holds more of them than a command line could
37
+ def only(config, path)
38
+ ResultFile.unpack_ids(File.read(path)).group_by { |id| id[/\A(.+)\[[^\]]+\]\z/, 1] }.each do |file, ids|
39
+ config.filter_manager.add_ids(file, ids.map { |id| id[/\[([^\]]+)\]\z/, 1] }) if file
40
+ end
41
+ end
42
+
43
+ # The leaks of a top-level group that none of its examples left: the state a before(:context)
44
+ # hook sets up and does not put back
45
+ def around_group(group, before)
46
+ return unless before
47
+
48
+ snapshot, reported = before
49
+ leaks = Leaks.own(Leaks.since(snapshot), RSpecFormatter.reported.drop(reported))
50
+ RSpecFormatter.group_leaks(group.description, leaks) if leaks.any?
51
+ end
52
+
53
+ def top_level?(group)
54
+ group.superclass.equal?(RSpec::Core::ExampleGroup)
55
+ end
56
+ end
57
+ end
58
+
59
+ RSpec.configure do |config|
60
+ if (path = ENV.delete(Deflake::ResultFile::ENV_KEY))
61
+ # Here, not with --format: formatters given in SPEC_OPTS replace those on the command line
62
+ Deflake::RSpecFormatter.path = path
63
+ config.add_formatter(Deflake::RSpecFormatter)
64
+ config.singleton_class.prepend(Deflake::RSpecRun::Settings)
65
+ RSpec::Core::Reporter.prepend(Deflake::RSpecRun::Reporter)
66
+ order = ENV.delete(Deflake::ResultFile::ORDER_KEY)
67
+ Deflake::RSpecRun.only(config, order) if order
68
+
69
+ config.prepend_before(:context) do
70
+ if Deflake::RSpecRun.top_level?(self.class)
71
+ self.class.metadata[:deflake_group] = [Deflake::Leaks.snapshot, Deflake::RSpecFormatter.reported.size]
72
+ end
73
+ end
74
+ config.append_after(:context) do
75
+ Deflake::RSpecRun.around_group(self.class, self.class.metadata.delete(:deflake_group))
76
+ end
77
+ end
78
+
79
+ # Declared before the project's own hooks, so it wraps them all and a rolled
80
+ # back transaction or a travel_back in an after hook is not reported as a leak
81
+ config.around do |example|
82
+ before = Deflake::Leaks.snapshot
83
+ begin
84
+ example.run
85
+ ensure
86
+ # Also when a hook inside fails after the example ran
87
+ example.metadata[:deflake_leaks] = Deflake::Leaks.since(before)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require_relative "result_file"
5
+
6
+ module Deflake
7
+ # Writes the result of every example to a file, one JSON line per example
8
+ class RSpecFormatter
9
+ ::RSpec::Core::Formatters.register self, :start, :example_started, :example_passed, :example_failed,
10
+ :example_pending
11
+
12
+ # Failures of expectations, whose message says it all, as Minitest's assertions do
13
+ EXPECTATIONS = %w[RSpec::Expectations::ExpectationNotMetError
14
+ RSpec::Expectations::MultipleExpectationsNotMetError
15
+ RSpec::Mocks::MockExpectationError].freeze
16
+
17
+ class << self
18
+ # Where to write, taken from the environment before the tests can see it
19
+ attr_accessor :path
20
+
21
+ # Every leak the examples reported so far
22
+ def reported
23
+ @reported ||= []
24
+ end
25
+
26
+ # An error outside of examples, such as in an after(:context) hook: its class and message
27
+ # without the backtrace, which would split one error into many
28
+ def outside(context, exception)
29
+ ResultFile.append(path, outside: "#{context}\n#{describe(exception)}")
30
+ rescue StandardError
31
+ nil
32
+ end
33
+
34
+ def group_leaks(group, leaks)
35
+ ResultFile.append(path, outside: nil, leaks: leaks.map { |leak| "#{group}, outside its examples: #{leak}" })
36
+ end
37
+
38
+ # The class too, so errors of two classes with one message are not one error. The message
39
+ # may be anything an exception class returns, nil included
40
+ def describe(exception)
41
+ message = exception.message.to_s.strip
42
+ EXPECTATIONS.include?(exception.class.name) ? message : "#{exception.class}: #{message}"
43
+ rescue StandardError, ScriptError
44
+ exception.class.name.to_s
45
+ end
46
+ end
47
+
48
+ def initialize(_output)
49
+ @path = self.class.path
50
+ end
51
+
52
+ # The file exists once RSpec starts, so a run where no example wrote a line still tells that
53
+ # the process got going
54
+ def start(_notification)
55
+ ResultFile.start(@path)
56
+ end
57
+
58
+ def example_started(notification)
59
+ ResultFile.append(@path, started: notification.example.id, name: notification.example.full_description)
60
+ end
61
+
62
+ def example_passed(notification)
63
+ record(notification.example, nil)
64
+ end
65
+
66
+ def example_failed(notification)
67
+ record(notification.example, self.class.describe(notification.example.exception))
68
+ end
69
+
70
+ # An example marked pending that failed as expected passed: its run counts.
71
+ # One that skip or xit left out is skipped, and only its leaks count
72
+ def example_pending(notification)
73
+ example = notification.example
74
+ return record(example, nil) unless example.execution_result.example_skipped?
75
+
76
+ ResultFile.append(@path, id: example.id, name: example.full_description, skipped: true,
77
+ seed: ::RSpec.configuration.seed, leaks: leaks(example))
78
+ end
79
+
80
+ private
81
+
82
+ def record(example, error)
83
+ ResultFile.append(@path, id: example.id, name: example.full_description, passed: error.nil?,
84
+ seed: ::RSpec.configuration.seed, error: error, leaks: leaks(example))
85
+ end
86
+
87
+ def leaks(example)
88
+ (example.metadata[:deflake_leaks] || []).tap { |leaks| self.class.reported.concat(leaks) }
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ # Result of a single test run, with messages about the global state it leaked.
5
+ # group: what the error is grouped by instead of its text, when the text changes from run to run
6
+ Run = Data.define(:seed, :passed, :error, :leaks, :group) do
7
+ def initialize(seed:, passed:, error:, leaks: [], group: nil)
8
+ super
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ # Runs a test file many times, each in a fresh process with a new seed
5
+ class Runner
6
+ # Seeds Minitest and RSpec accept
7
+ SEEDS = 0xFFFF
8
+
9
+ # only: run just one test, by its Summary#id; jobs: how many processes at once
10
+ def initialize(path, runs:, only: nil, jobs: 1, adapter: Adapters.for(path))
11
+ raise ArgumentError, "runs must be positive" unless runs.positive?
12
+ raise ArgumentError, "jobs must be positive" unless jobs.positive?
13
+
14
+ @path = path
15
+ @count = runs
16
+ @only = only
17
+ @jobs = jobs
18
+ @adapter = adapter
19
+ end
20
+
21
+ # Ids of every test that started in the last call, even those left out of its result
22
+ attr_reader :seen
23
+ # Every summary of the last call, the quiet ones too, and every test's name by its id
24
+ attr_reader :all, :names
25
+
26
+ # Returns a hash of test id => Summary. Runs where a test skipped itself count only for its
27
+ # leaks, and a test that skipped itself every time and leaked nothing is left out.
28
+ # Errors outside of any test, in a run of the whole file, show up as Execution::OUTSIDE
29
+ def call
30
+ rows = in_parallel(seeds) { |seed, worker| execute(seed, worker_env(worker)) }
31
+ check_some_ran(rows)
32
+ check_some_tests(rows)
33
+ # The id, not the name: RSpec examples can share a description
34
+ remember(rows.group_by { |row| row["id"] })
35
+ @all.reject { |id, summary| quiet?(id, summary) }
36
+ end
37
+
38
+ private
39
+
40
+ def remember(groups)
41
+ @seen = groups.keys
42
+ @all = groups.to_h { |id, group| [id, summarize(id, group)] }
43
+ @names = @all.transform_values(&:name)
44
+ end
45
+
46
+ # A run that broke before its first test is reported among the others, unless every run did
47
+ def check_some_ran(rows)
48
+ silent = rows.select { |row| row.key?("output") }
49
+ return if silent.empty? || silent.size < rows.size
50
+
51
+ raise Execution::NoResults.new(ResultFile.clean(@path), silent.first["output"], silent.first["ending"])
52
+ end
53
+
54
+ # A file without tests, or whose tests a filter of the project leaves out, is a mistake, as a
55
+ # line without a test is: a passing report would hide it
56
+ def check_some_tests(rows)
57
+ return if @only || rows.any? { |row| row["id"] != Execution::OUTSIDE || row["ran"] }
58
+ return unless rows.all? { |row| quiet_outside?(row) }
59
+
60
+ raise Error, "no tests ran in #{ResultFile.clean(@path)} (no tests in it, or a filter leaves them all out)"
61
+ end
62
+
63
+ def quiet_outside?(row)
64
+ row["passed"] && row["leaks"].empty?
65
+ end
66
+
67
+ # Each run gets a seed of its own while there are enough of them
68
+ def seeds
69
+ return Array.new(@count) { rand(SEEDS) } if @count > SEEDS
70
+
71
+ (0...SEEDS).to_a.sample(@count)
72
+ end
73
+
74
+ def execute(seed, env)
75
+ return Execution.whole_file(@adapter, @path, seed: seed, env: env) unless @only
76
+
77
+ Execution.some(@adapter, @path, seed: seed, only: @only, env: env)
78
+ end
79
+
80
+ # Threads only wait for the test processes, so they do not fight over Ruby's lock
81
+ def in_parallel(seeds, &block)
82
+ queue = Queue.new(seeds).tap(&:close)
83
+ workers = Array.new([@jobs, seeds.size].min) do |worker|
84
+ Thread.new do
85
+ # The error reaches the caller through Thread#value, no need to print it here too
86
+ Thread.current.report_on_exception = false
87
+ drain(queue, worker, &block)
88
+ end
89
+ end
90
+ workers.flat_map(&:value)
91
+ ensure
92
+ stop(workers)
93
+ end
94
+
95
+ # On an error or an interrupt the other workers stop too, killing their test processes
96
+ def stop(workers)
97
+ workers&.each(&:kill)&.each do |worker|
98
+ worker.join
99
+ rescue StandardError
100
+ nil
101
+ end
102
+ end
103
+
104
+ def drain(queue, worker)
105
+ rows = []
106
+ while (seed = queue.pop)
107
+ rows.concat(yield(seed, worker))
108
+ end
109
+ rows
110
+ end
111
+
112
+ # Same convention as parallel_tests: "", "2", "3"... so each process can use its own database
113
+ def worker_env(worker)
114
+ return {} if @jobs == 1
115
+
116
+ { "TEST_ENV_NUMBER" => worker.zero? ? "" : (worker + 1).to_s }
117
+ end
118
+
119
+ # Leaks outside of tests, such as those of a before(:all) hook, are not quiet either
120
+ def quiet?(id, summary)
121
+ return summary.failures.zero? && summary.leaks.empty? if id == Execution::OUTSIDE
122
+
123
+ summary.total.zero? && summary.leaks.empty?
124
+ end
125
+
126
+ def summarize(id, rows)
127
+ skipped, ran = rows.partition { |row| row["skipped"] }
128
+ runs = ran.map do |row|
129
+ Run.new(seed: row["seed"], passed: row["passed"], error: row["error"], leaks: row.fetch("leaks", []),
130
+ group: row["group"])
131
+ end
132
+ Summary.new(runs, id: id, name: name_of(rows), skipped: skipped.size,
133
+ skipped_leaks: skipped.flat_map { |row| row.fetch("leaks", []) })
134
+ end
135
+
136
+ # RSpec names a one-liner only once its matcher runs, so a passing run has the full name
137
+ def name_of(rows)
138
+ named = rows.select { |row| row["passed"] }
139
+ named = rows if named.empty?
140
+ named.map { |row| row["name"] }.tally.max_by(&:last).first
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ # Summary of all runs of a single test
5
+ class Summary
6
+ # How to run only this test: "Class#test_name" or an RSpec example id
7
+ attr_reader :id
8
+ # Readable test name
9
+ attr_reader :name
10
+ # In how many runs the test skipped itself; those runs are not in total
11
+ attr_reader :skipped
12
+
13
+ # skipped_leaks: leaks of the runs where the test skipped itself
14
+ def initialize(runs, id: nil, name: id, skipped: 0, skipped_leaks: [])
15
+ @runs = runs
16
+ @id = id
17
+ @name = name
18
+ @skipped = skipped
19
+ @skipped_leaks = skipped_leaks
20
+ end
21
+
22
+ def total
23
+ @runs.size
24
+ end
25
+
26
+ def failures
27
+ @runs.count { |run| !run.passed }
28
+ end
29
+
30
+ def failure_rate
31
+ total.zero? ? 0.0 : failures.fdiv(total)
32
+ end
33
+
34
+ # Messages that differ only in the addresses inside inspected objects ("#<User:0x000055...>"),
35
+ # which change in every process, are one error. Other hexadecimal numbers are kept. Runs with
36
+ # a group, such as a crash whose output depends on the seed, show the error of the first one
37
+ def errors
38
+ groups = @runs.reject(&:passed).group_by { |run| run.group || normalize(run.error) }.values
39
+ groups.each_with_object({}) do |runs, all|
40
+ (all[normalize(runs.first.error)] ||= []).concat(runs.map(&:seed))
41
+ end
42
+ end
43
+
44
+ # Leak message => in how many runs it happened, skipped runs included
45
+ def leaks
46
+ (@runs.flat_map(&:leaks) + @skipped_leaks).tally
47
+ end
48
+
49
+ private
50
+
51
+ def normalize(message)
52
+ message&.gsub(/(#<(?:[A-Z]\w*::)*[^\s>:]+:)0x\h{8,16}/, '\10x...')
53
+ end
54
+ end
55
+ end