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
data/lib/deflake/cli.rb
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Deflake
|
|
6
|
+
# Command-line entry point
|
|
7
|
+
class CLI
|
|
8
|
+
REPORTS = { "text" => TextReport, "json" => JsonReport }.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(argv, out: $stdout, err: $stderr)
|
|
11
|
+
@argv = argv.dup
|
|
12
|
+
@out = out
|
|
13
|
+
@err = err
|
|
14
|
+
@options = { runs: 10, jobs: 1, format: "text", timeout: nil }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the process exit code
|
|
18
|
+
def run
|
|
19
|
+
dispatch
|
|
20
|
+
rescue OptionParser::ParseError, Error => e
|
|
21
|
+
complain(e.message, usage: e.is_a?(OptionParser::ParseError))
|
|
22
|
+
rescue Interrupt
|
|
23
|
+
interrupted
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
# 1 would mean flaky tests were found, so anything unexpected is a 2 as well
|
|
26
|
+
complain("unexpected error, please report it: #{e.class}: #{e.message}\n #{e.backtrace&.first(5)&.join("\n ")}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def dispatch
|
|
32
|
+
case parse_command
|
|
33
|
+
when :version then @out.puts "deflake #{VERSION}"
|
|
34
|
+
when :help then @out.puts parser
|
|
35
|
+
else return run_tests
|
|
36
|
+
end
|
|
37
|
+
0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The test processes are stopped on the way out
|
|
41
|
+
def interrupted
|
|
42
|
+
@err.puts "deflake: interrupted"
|
|
43
|
+
130
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def complain(message, usage: false)
|
|
47
|
+
@err.puts "deflake: #{message}"
|
|
48
|
+
@err.puts parser if usage
|
|
49
|
+
2
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse_command
|
|
53
|
+
command = nil
|
|
54
|
+
define_options
|
|
55
|
+
parser.on("-v", "--version", "Print version and exit") { command = :version }
|
|
56
|
+
parser.on("-h", "--help", "Show this help and exit") { command = :help }
|
|
57
|
+
parser.parse!(@argv)
|
|
58
|
+
command || check_paths
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def check_paths
|
|
62
|
+
raise OptionParser::MissingArgument, "TEST_PATH" if @argv.empty? || @argv.first.empty?
|
|
63
|
+
raise OptionParser::NeedlessArgument, "#{@argv.drop(1).join(" ")} (one TEST_PATH at a time)" if @argv.size > 1
|
|
64
|
+
|
|
65
|
+
:run
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def define_options
|
|
69
|
+
count(:runs, "-n", "--runs N", "How many times to run the file (default: 10)")
|
|
70
|
+
count(:jobs, "-j", "--jobs N", "How many test processes to run at once (default: 1)")
|
|
71
|
+
count(:timeout, "-t", "--timeout SECONDS", "Stop a test process that runs longer (default: no limit)")
|
|
72
|
+
parser.on("-f", "--format FORMAT", REPORTS.keys, "Report format: text or json (default: text)") do |format|
|
|
73
|
+
@options[:format] = format
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Decimal, not Integer: Integer reads 010 as 8
|
|
78
|
+
def count(option, *switches)
|
|
79
|
+
parser.on(*switches[0, 2], OptionParser::DecimalInteger, switches[2]) { |n| @options[option] = positive(n) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def positive(number)
|
|
83
|
+
raise OptionParser::InvalidArgument, "#{number} (must be 1 or more)" unless number.positive?
|
|
84
|
+
|
|
85
|
+
number
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def run_tests
|
|
89
|
+
path, line = target
|
|
90
|
+
raise Error, "no such file: #{path}" unless File.file?(path)
|
|
91
|
+
|
|
92
|
+
Execution.timeout = @options[:timeout]
|
|
93
|
+
investigation = Investigation.new(path, line: line, runs: @options[:runs], jobs: @options[:jobs])
|
|
94
|
+
diagnoses = investigation.call
|
|
95
|
+
report(diagnoses, line, investigation)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A file whose name itself ends in ":12" is that file, not line 12 of another. The path is
|
|
99
|
+
# taken as UTF-8 whatever the locale, as names in the report are
|
|
100
|
+
def target
|
|
101
|
+
argument = ResultFile.clean(@argv.first)
|
|
102
|
+
return [argument, nil] if File.file?(argument)
|
|
103
|
+
|
|
104
|
+
path, line = argument.match(/\A(.+?)(?::(\d+))?\z/m).captures
|
|
105
|
+
[path, line&.to_i]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A test on the line that never started is an error: something stopped every run before it,
|
|
109
|
+
# or the file's own filters leave it out
|
|
110
|
+
def report(diagnoses, line, investigation)
|
|
111
|
+
not_run = investigation.not_run
|
|
112
|
+
if not_run.any?
|
|
113
|
+
@err.puts "deflake: #{not_run.join(", ")} never ran in a run of the whole file " \
|
|
114
|
+
"(a filter of the file leaves it out, or something stops every run before it)"
|
|
115
|
+
end
|
|
116
|
+
# As with a plain run, tests that all skipped themselves are not an error
|
|
117
|
+
@err.puts "deflake: #{skipped(line)}, nothing to report" if diagnoses.empty? && not_run.empty?
|
|
118
|
+
print_report(diagnoses, not_run: not_run, shared: investigation.shared_names)
|
|
119
|
+
diagnoses.all?(&:clean?) && not_run.empty? ? 0 : 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A reader that stops early, such as head, is not an error of deflake's
|
|
123
|
+
def print_report(diagnoses, **)
|
|
124
|
+
REPORTS.fetch(@options[:format]).new(@out).print(diagnoses, **)
|
|
125
|
+
rescue Errno::EPIPE
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def skipped(line)
|
|
130
|
+
line ? "the test at line #{line} skipped itself in every run" : "every test skipped itself"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parser
|
|
134
|
+
@parser ||= OptionParser.new("Usage: deflake TEST_PATH[:LINE] [options]")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deflake
|
|
4
|
+
# Tests that make another test fail when they run before it, and a command
|
|
5
|
+
# that shows the failure. ids is empty when bisect could not reproduce the failure,
|
|
6
|
+
# or when the test failed with that seed with no other test before it (alone)
|
|
7
|
+
Culprits = Data.define(:ids, :command, :names, :alone, :hidden, :group_wide) do
|
|
8
|
+
# names: readable test names, the ids themselves by default.
|
|
9
|
+
# group_wide: culprits that other tests of their group stand in for, as when the
|
|
10
|
+
# state comes from a hook of the group, such as before(:context), and not from the test.
|
|
11
|
+
# hidden: the failure did not happen again, but tests that cannot run by name ran in the file
|
|
12
|
+
def initialize(ids:, command:, names: ids, alone: false, hidden: false, group_wide: [])
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def found? = ids.any?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deflake
|
|
4
|
+
# Everything deflake found out about one test.
|
|
5
|
+
# isolated_runs and culprits are nil when there was nothing to investigate,
|
|
6
|
+
# and isolated_runs also when the failure could not run alone: errors outside of tests,
|
|
7
|
+
# or a test whose runs alone all broke before it started
|
|
8
|
+
Diagnosis = Data.define(:name, :id, :file_runs, :isolated_runs, :culprits) do
|
|
9
|
+
# :stable, :failed (could not run alone), :fails_on_its_own, :order_dependent, or :random:
|
|
10
|
+
# it passed every time alone, and the failure did not happen again with the same seed and order,
|
|
11
|
+
# with every test of the file that ran before it
|
|
12
|
+
def verdict
|
|
13
|
+
return :stable if file_runs.failures.zero?
|
|
14
|
+
return :failed if isolated_runs.nil?
|
|
15
|
+
return :fails_on_its_own if fails_alone?
|
|
16
|
+
|
|
17
|
+
culprits.nil? || culprits.found? || culprits.hidden ? :order_dependent : :random
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# It failed in a run alone, or with no test before it in a bisect
|
|
21
|
+
def fails_alone?
|
|
22
|
+
isolated_runs.failures.positive? || culprits&.alone || false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Nothing failed and nothing leaked
|
|
26
|
+
def clean?
|
|
27
|
+
verdict == :stable && file_runs.leaks.empty?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require_relative "outcome"
|
|
5
|
+
|
|
6
|
+
module Deflake
|
|
7
|
+
# Runs a test file once in a fresh process and returns one row per test that started, in run order
|
|
8
|
+
module Execution
|
|
9
|
+
# Id of the row that stands for errors outside of any test, such as in an after(:context) hook
|
|
10
|
+
OUTSIDE = "(outside of tests)"
|
|
11
|
+
# Runs the tests of a process one at a time
|
|
12
|
+
SERIAL = { "PARALLEL_WORKERS" => "1", "MT_CPU" => "1" }.freeze
|
|
13
|
+
# Raised when a test process wrote no results at all; output is what it printed,
|
|
14
|
+
# ending how the process ended, such as "timed out after 5 seconds"
|
|
15
|
+
class NoResults < Error
|
|
16
|
+
attr_reader :output, :ending
|
|
17
|
+
|
|
18
|
+
# success: whether the process itself exited with success
|
|
19
|
+
def initialize(target, output, ending = nil, success: false)
|
|
20
|
+
@output = output
|
|
21
|
+
@ending = ending
|
|
22
|
+
@success = success
|
|
23
|
+
super("#{target} produced no test results#{" (the test process #{ending})" if ending}:\n#{output}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def success? = @success
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Seconds a test process may run before it is killed, nil for no limit
|
|
31
|
+
attr_accessor :timeout
|
|
32
|
+
|
|
33
|
+
# only: what to run instead of the whole file; env: extra environment variables for the test process
|
|
34
|
+
def call(adapter, path, seed:, only: nil, env: {})
|
|
35
|
+
complete(run(adapter, path, seed, only, env), only || path).rows
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The ids of the whole file's tests in the order they ran, and whether tests that cannot run
|
|
39
|
+
# by name, such as those in a class without a name, ran too
|
|
40
|
+
def order(adapter, path, seed:)
|
|
41
|
+
process = complete(run(adapter, path, seed, nil, {}), path)
|
|
42
|
+
[process.rows.map { |row| row["id"] }, process.unnamed.any?]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Tries again when the test process wrote no results, as a flaky boot does now and then
|
|
46
|
+
def retrying(attempts = 3)
|
|
47
|
+
yield
|
|
48
|
+
rescue NoResults
|
|
49
|
+
attempts -= 1
|
|
50
|
+
retry if attempts.positive?
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Runs the whole file and adds an OUTSIDE row telling whether the process failed on its own.
|
|
55
|
+
# A process that wrote no results at all is one failed OUTSIDE row carrying its output
|
|
56
|
+
def whole_file(adapter, path, seed:, env: {})
|
|
57
|
+
process = run(adapter, path, seed, nil, env)
|
|
58
|
+
return [no_results_row(seed, process)] unless process.rows
|
|
59
|
+
|
|
60
|
+
process.rows + [outside_row(process, seed)]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Runs only some tests. As in whole_file, a process that wrote no results is a failed OUTSIDE
|
|
64
|
+
# row, and so is one where none of these tests started: only a failure that cannot run by
|
|
65
|
+
# name, such as a class-level hook, may have run
|
|
66
|
+
def some(adapter, path, seed:, only:, env: {})
|
|
67
|
+
process = run(adapter, path, seed, only, env)
|
|
68
|
+
process.rows.nil? || process.rows.empty? ? [no_results_row(seed, process)] : process.rows
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def no_results_row(seed, process)
|
|
72
|
+
message = "the test process #{ending(process.status)} before any test finished"
|
|
73
|
+
failures = outside_failures(process)
|
|
74
|
+
error = failures ? "#{message}:\n#{failures}" : with_tail(message, process.output)
|
|
75
|
+
{ "id" => OUTSIDE, "name" => "errors outside of tests", "passed" => false, "seed" => seed,
|
|
76
|
+
"error" => error, "output" => process.output, "ending" => ending(process.status), "leaks" => [] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ending(status) = Child.ending(status)
|
|
80
|
+
|
|
81
|
+
def with_tail(message, output) = Tail.with(message, output)
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def complete(process, target)
|
|
86
|
+
return process if process.rows
|
|
87
|
+
|
|
88
|
+
raise NoResults.new(ResultFile.clean(Array(target).join(" ")), process.output, ending(process.status),
|
|
89
|
+
success: process.status.success?)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def run(adapter, path, seed, only, env)
|
|
93
|
+
Dir.mktmpdir do |dir|
|
|
94
|
+
output = File.join(dir, "results.jsonl")
|
|
95
|
+
log = File.join(dir, "output.log")
|
|
96
|
+
# Rails' parallelize and Minitest's parallelize_me! would run tests at once, in no fixed
|
|
97
|
+
# order, and the leaks of one would show up in the others. Variables a test process of an
|
|
98
|
+
# outer deflake got are not passed on
|
|
99
|
+
env = ResultFile::KEYS.to_h { |key| [key, nil] }
|
|
100
|
+
.merge(env, adapter.env(only), ResultFile::ENV_KEY => output, **SERIAL)
|
|
101
|
+
status = Child.run(env, adapter.command(path, seed: seed, only: only), log, timeout)
|
|
102
|
+
finish(File.exist?(output) ? ResultFile.read(output) : nil, seed, status, ResultFile.clean(Tail.read(log)))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A test that started and never finished took its process down with it, so it counts as failed.
|
|
107
|
+
# A failure that cannot be run alone by name, such as a test in a class without a name or a
|
|
108
|
+
# class-level hook, is told about as an error outside of tests. A process that wrote nothing,
|
|
109
|
+
# or failed before any test, has no rows
|
|
110
|
+
def finish(rows, seed, status, output)
|
|
111
|
+
return Outcome.new(nil, [], [], status, output) unless rows
|
|
112
|
+
|
|
113
|
+
outside, rows = rows.partition { |row| row.key?("outside") }
|
|
114
|
+
unnamed, rows = rows.partition { |row| row.key?("unnamed") }
|
|
115
|
+
outcome = Outcome.new(tests(rows, seed, status, output), unnamed, outside, status, output)
|
|
116
|
+
outcome.rows = nil unless outcome.rows.any? || outcome.unnamed_failures.any? || status.success?
|
|
117
|
+
outcome
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def tests(rows, seed, status, output)
|
|
121
|
+
started, finished = without_repeats(rows).partition { |row| row.key?("started") }
|
|
122
|
+
finished + lost(started, finished, seed, status, output)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# One result each time a test starts: RSpec may report an example again, without starting it,
|
|
126
|
+
# when its group breaks afterwards
|
|
127
|
+
def without_repeats(rows)
|
|
128
|
+
running = Hash.new(0)
|
|
129
|
+
rows.select do |row|
|
|
130
|
+
next running[row["started"]] += 1 if row.key?("started")
|
|
131
|
+
next true unless row.key?("id")
|
|
132
|
+
|
|
133
|
+
running[row["id"]].positive?.tap { |started| running[row["id"]] -= 1 if started }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def lost(started, finished, seed, status, output)
|
|
138
|
+
done = finished.to_h { |row| [row["id"], true] }
|
|
139
|
+
message = "the test process #{ending(status)} during this test"
|
|
140
|
+
started.reject { |row| done[row["started"]] }.uniq { |row| row["started"] }.map do |row|
|
|
141
|
+
# What the output shows before the crash changes with the order of the tests
|
|
142
|
+
{ "id" => row["started"], "name" => row["name"], "passed" => false, "seed" => seed,
|
|
143
|
+
"error" => with_tail(message, output), "group" => message,
|
|
144
|
+
"timed_out" => status.is_a?(TimedOut), "leaks" => [] }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A process that failed while every test passed broke somewhere else, for example in a hook.
|
|
149
|
+
# Errors outside of tests that the test framework told about count even when a test failed.
|
|
150
|
+
# ran: whether any test ran, even one that cannot run by name
|
|
151
|
+
def outside_row(process, seed)
|
|
152
|
+
error = outside_failures(process)
|
|
153
|
+
passed = error.nil? && (process.status.success? || process.failed_test?)
|
|
154
|
+
{ "id" => OUTSIDE, "name" => "errors outside of tests", "passed" => passed, "seed" => seed,
|
|
155
|
+
"error" => (passed ? nil : error || outside_tail(process)), "leaks" => process.leaks, "ran" => process.ran? }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def outside_tail(process)
|
|
159
|
+
with_tail("the test process #{ending(process.status)} although no test failed", process.output)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Sorted, as the order they happened in changes with the seed
|
|
163
|
+
def outside_failures(process)
|
|
164
|
+
failures = process.unnamed_failures.map { |row| "#{row["unnamed"]}: #{row["error"]}" }.uniq.sort
|
|
165
|
+
failures.unshift("a failure deflake cannot run alone by name:") if failures.any?
|
|
166
|
+
text = (process.outside.filter_map { |row| row["outside"] }.uniq.sort + failures).join("\n")
|
|
167
|
+
text unless text.empty?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
TimedOut = Child::TimedOut
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deflake
|
|
4
|
+
# Runs a test file many times, then reruns every failing test on its own
|
|
5
|
+
# to tell a flaky test from one broken by its neighbours, and bisects the latter
|
|
6
|
+
class Investigation
|
|
7
|
+
# How many failing seeds bisect tries before it gives up
|
|
8
|
+
SEEDS = 3
|
|
9
|
+
|
|
10
|
+
# line: look only at the test on this line, but still run the whole file
|
|
11
|
+
def initialize(path, runs:, line: nil, jobs: 1, adapter: Adapters.for(path))
|
|
12
|
+
@path = path
|
|
13
|
+
@runs = runs
|
|
14
|
+
@line = line
|
|
15
|
+
@jobs = jobs
|
|
16
|
+
@adapter = adapter
|
|
17
|
+
@names = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Ids of the tests on the line that never started in any run, as when an earlier test
|
|
21
|
+
# always takes the process down
|
|
22
|
+
attr_reader :not_run
|
|
23
|
+
|
|
24
|
+
# Returns an array of Diagnosis, sorted by test name. It is empty when every test skipped itself.
|
|
25
|
+
# With a line, errors outside of tests still show up: they may be why the test never ran
|
|
26
|
+
def call
|
|
27
|
+
# Before the runs, so a line without a test is reported right away
|
|
28
|
+
focus = @line && @adapter.focus(@path, @line)
|
|
29
|
+
summaries = focused(focus)
|
|
30
|
+
summaries.values.sort_by { |summary| [summary.name, summary.id] }.map { |summary| diagnose(summary) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Names that more than one test of the file has, so a report tells them apart by their ids
|
|
34
|
+
def shared_names
|
|
35
|
+
@names.values.tally.select { |_, count| count > 1 }.keys
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Summaries of the whole file, only of the tests in focus and errors outside of tests when there is one
|
|
41
|
+
def focused(focus)
|
|
42
|
+
file = runner
|
|
43
|
+
summaries = file.call
|
|
44
|
+
# Of every test that ran, including those that skipped themselves
|
|
45
|
+
@names = file.names
|
|
46
|
+
@not_run = focus ? focus - file.seen : []
|
|
47
|
+
return summaries unless focus
|
|
48
|
+
|
|
49
|
+
summaries.select { |id, _| focus.include?(id) || id == Execution::OUTSIDE }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def runner(only: nil)
|
|
53
|
+
Runner.new(@path, runs: @runs, only: only, jobs: @jobs, adapter: @adapter)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def diagnose(summary)
|
|
57
|
+
diagnosis = Diagnosis.new(name: summary.name, id: summary.id, file_runs: summary, isolated_runs: nil,
|
|
58
|
+
culprits: nil)
|
|
59
|
+
# Errors outside of tests cannot run on their own
|
|
60
|
+
return diagnosis if summary.failures.zero? || summary.id == Execution::OUTSIDE
|
|
61
|
+
|
|
62
|
+
diagnosis = diagnosis.with(isolated_runs: isolated(summary))
|
|
63
|
+
seeds = bisect_seeds(diagnosis)
|
|
64
|
+
seeds.empty? ? diagnosis : diagnosis.with(culprits: bisect(summary, seeds))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Failing seeds worth a bisect. A test that fails on its own may also fail after other tests,
|
|
68
|
+
# with an error it never had alone: its seeds are bisected too
|
|
69
|
+
def bisect_seeds(diagnosis)
|
|
70
|
+
errors = diagnosis.file_runs.errors
|
|
71
|
+
case diagnosis.verdict
|
|
72
|
+
when :order_dependent then seeds_of(errors)
|
|
73
|
+
when :fails_on_its_own then seeds_of(errors.except(*diagnosis.isolated_runs.errors.keys))
|
|
74
|
+
else []
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The first seed of every error first, as a single odd failure may be the first one
|
|
79
|
+
def seeds_of(errors)
|
|
80
|
+
firsts = errors.values.map(&:first)
|
|
81
|
+
(firsts + errors.values.flatten).uniq.first(SEEDS)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# When no run alone reached the test, nil: the test failed, and whether it fails on its own
|
|
85
|
+
# is not known. A test that skipped itself every time it ran alone, and fails only after
|
|
86
|
+
# other tests, is order dependent
|
|
87
|
+
def isolated(summary)
|
|
88
|
+
alone = runner(only: summary.id)
|
|
89
|
+
alone.call
|
|
90
|
+
alone.all[summary.id]
|
|
91
|
+
rescue Execution::NoResults
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Tries the seeds one by one until bisect finds culprits, or finds the test failing alone
|
|
96
|
+
def bisect(summary, seeds)
|
|
97
|
+
culprits = nil
|
|
98
|
+
seeds.each do |seed|
|
|
99
|
+
culprits = @adapter.bisect(@path, summary.id, seed: seed)
|
|
100
|
+
break if culprits.found? || culprits.alone
|
|
101
|
+
end
|
|
102
|
+
culprits.with(names: culprits.ids.map { |id| label(id, culprits.group_wide.include?(id)) })
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Tests with the same name are told apart by their ids
|
|
106
|
+
def label(id, group_wide)
|
|
107
|
+
name = @names.fetch(id, id)
|
|
108
|
+
name = "#{name} (#{id})" if @names.count { |_, other| other == name } > 1
|
|
109
|
+
group_wide ? "#{name} or any other test of its group (a hook of the group?)" : name
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Deflake
|
|
6
|
+
# Prints diagnoses as JSON for CI and other tools. The format may change before 1.0
|
|
7
|
+
class JsonReport
|
|
8
|
+
def initialize(out)
|
|
9
|
+
@out = out
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# not_run: ids of tests on the line that never started, each one an object with the verdict "not_run"
|
|
13
|
+
def print(diagnoses, not_run: [], **)
|
|
14
|
+
objects = diagnoses.map { |diagnosis| as_json(diagnosis) } + not_run.map { |id| never_ran(id) }
|
|
15
|
+
# Names and paths in another encoding, as under LANG=C, would stop the generator
|
|
16
|
+
@out.puts JSON.pretty_generate(ResultFile.clean(objects))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def as_json(diagnosis)
|
|
22
|
+
runs = diagnosis.file_runs
|
|
23
|
+
{ name: diagnosis.name, id: diagnosis.id, verdict: diagnosis.verdict,
|
|
24
|
+
runs: counts(runs), isolated_runs: diagnosis.isolated_runs && counts(diagnosis.isolated_runs),
|
|
25
|
+
errors: runs.errors.map { |message, seeds| { message: message, seeds: seeds } },
|
|
26
|
+
leaks: runs.leaks.map { |message, count| { message: message, runs: count } },
|
|
27
|
+
culprits: diagnosis.culprits&.to_h }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def never_ran(id)
|
|
31
|
+
{ name: id, id: id, verdict: :not_run, runs: counts(Summary.new([], id: id)), isolated_runs: nil,
|
|
32
|
+
errors: [], leaks: [], culprits: nil }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def counts(summary)
|
|
36
|
+
# Not rounded: one failure in thousands of runs is not 0
|
|
37
|
+
{ total: summary.total, failures: summary.failures, failure_rate: summary.failure_rate,
|
|
38
|
+
skipped: summary.skipped }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deflake
|
|
4
|
+
module Leaks
|
|
5
|
+
# Keys left in Rails.cache, when it is a MemoryStore
|
|
6
|
+
class Cache
|
|
7
|
+
# How many of the new keys a message lists
|
|
8
|
+
SHOWN = 3
|
|
9
|
+
|
|
10
|
+
def snapshot
|
|
11
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:cache)
|
|
12
|
+
return unless defined?(::ActiveSupport::Cache::MemoryStore)
|
|
13
|
+
|
|
14
|
+
store = ::Rails.cache
|
|
15
|
+
store.instance_variable_get(:@data).keys if store.is_a?(::ActiveSupport::Cache::MemoryStore)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def diff(before, after)
|
|
19
|
+
added = after - before
|
|
20
|
+
return [] if added.empty?
|
|
21
|
+
|
|
22
|
+
# Long numbers, such as times and versions, change from run to run
|
|
23
|
+
listed = added.first(SHOWN).map { |key| key.inspect.gsub(/\d{6,}/, "N") }.join(", ")
|
|
24
|
+
listed += ", ..." if added.size > SHOWN
|
|
25
|
+
["Rails.cache has #{added.size} new #{added.size == 1 ? "key" : "keys"}: #{listed}"]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deflake
|
|
4
|
+
module Leaks
|
|
5
|
+
# Time.now frozen or moved away from the real clock: travel_to, freeze_time or Timecop left on
|
|
6
|
+
class Clock
|
|
7
|
+
# Seconds Time.now may be off without being a leak: the real clock moves while it is read
|
|
8
|
+
TOLERANCE = 1
|
|
9
|
+
# How long to watch Time.now to tell whether it moves
|
|
10
|
+
TICK = 0.0001
|
|
11
|
+
# The wait for the clock to move must end whatever happens
|
|
12
|
+
CHECKS = 100_000
|
|
13
|
+
# Taken when deflake loads, before the tests: Timecop's mock_process_clock replaces
|
|
14
|
+
# Process.clock_gettime, and the real clock would move with the mocked one
|
|
15
|
+
CLOCK = Process.method(:clock_gettime)
|
|
16
|
+
|
|
17
|
+
# [offset from the real clock in seconds, whether Time.now stands still]
|
|
18
|
+
def snapshot
|
|
19
|
+
first = now
|
|
20
|
+
start = real
|
|
21
|
+
CHECKS.times { break if real - start >= TICK }
|
|
22
|
+
[first.to_f - start, now == first]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Where the clock ends up counts: a test that brings back a clock another test moved
|
|
26
|
+
# leaves nothing behind, and one that moves it says how far from the real clock it is
|
|
27
|
+
def diff(before, after)
|
|
28
|
+
(offset_before, frozen_before), (offset_after, frozen_after) = before, after
|
|
29
|
+
return [] if frozen_before
|
|
30
|
+
if frozen_after
|
|
31
|
+
# Without the offset, which grows with every second the clock stands still
|
|
32
|
+
return ["Time.now is frozen (freeze_time or travel_to without travel_back?)"]
|
|
33
|
+
end
|
|
34
|
+
return [] if (offset_after - offset_before).abs < TOLERANCE || offset_after.abs < TOLERANCE
|
|
35
|
+
|
|
36
|
+
["Time.now is#{shift(offset_after)} (travel_to without travel_back?)"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def now = ::Time.now
|
|
42
|
+
def real = CLOCK.call(Process::CLOCK_REALTIME)
|
|
43
|
+
|
|
44
|
+
def shift(seconds)
|
|
45
|
+
return "" if seconds.abs < TOLERANCE
|
|
46
|
+
|
|
47
|
+
" #{duration(seconds.abs)} #{seconds.positive? ? "ahead of" : "behind"} the real clock"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# In whole seconds, so a day measured a moment early is still a day
|
|
51
|
+
def duration(seconds)
|
|
52
|
+
seconds = seconds.round
|
|
53
|
+
[[86_400, "day"], [3600, "hour"], [60, "minute"], [1, "second"]].each do |size, unit|
|
|
54
|
+
count = seconds.fdiv(size).round
|
|
55
|
+
return "#{count} #{unit}#{"s" unless count == 1}" if count.positive? && seconds >= size
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|