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,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
|
data/lib/deflake/run.rb
ADDED
|
@@ -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
|