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/tail.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deflake
|
|
4
|
+
# The end of a test process's output, where test frameworks say what went wrong, cleaned of
|
|
5
|
+
# what changes from run to run, so one error stays one error
|
|
6
|
+
module Tail
|
|
7
|
+
# How many lines an error shows
|
|
8
|
+
LINES = 15
|
|
9
|
+
# How much of the end of the output is read: a log of hundreds of megabytes stays on disk
|
|
10
|
+
KEEP = 256 * 1024
|
|
11
|
+
# Whole lines the test frameworks print that change from run to run (the seed, the timing,
|
|
12
|
+
# the counts, the progress dots)
|
|
13
|
+
NOISE = Regexp.union(/\A(?:Run options:|Randomized with seed \d+\z|Finished in |# Running:\z|[.FESP*]+\z)/,
|
|
14
|
+
/\A\d+ runs?, \d+ assertions?/, /\A\d+ examples?, \d+ failures?/)
|
|
15
|
+
# Progress dots stuck to the start of a line the tests printed, but not those of "./path"
|
|
16
|
+
PROGRESS = %r{\A[.FESP*]*\.(?![./])}
|
|
17
|
+
# A seed inside a line, such as "Started with run options --seed 41234"
|
|
18
|
+
SEED = /\b(seed[ =:]+)\d+/i
|
|
19
|
+
# Frames of a backtrace, which Ruby prints after the message
|
|
20
|
+
FRAME = /\A\s+from /
|
|
21
|
+
# Frames kept of each backtrace
|
|
22
|
+
FRAMES = 3
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# The message, and the end of the output when there is one. Long backtraces are cut short,
|
|
27
|
+
# so the message before them stays in
|
|
28
|
+
def with(message, output)
|
|
29
|
+
tail = lines(output)
|
|
30
|
+
tail.empty? ? message : "#{message}, its output ended with:\n#{tail.join("\n")}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read(log)
|
|
34
|
+
File.open(log, "rb") do |file|
|
|
35
|
+
cut = file.size > KEEP
|
|
36
|
+
file.seek(-KEEP, IO::SEEK_END) if cut
|
|
37
|
+
text = file.read
|
|
38
|
+
cut ? text.sub(/\A[^\n]*\n/, "") : text
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def lines(output)
|
|
43
|
+
lines = without_pending(output.lines.map(&:rstrip)).map { |line| line.sub(PROGRESS, "").gsub(SEED, '\1N') }
|
|
44
|
+
short_frames(lines.reject { |line| line.empty? || line.strip.match?(NOISE) }).last(LINES)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# RSpec lists pending examples in the order they ran, which changes with the seed
|
|
48
|
+
def without_pending(lines)
|
|
49
|
+
skipping = false
|
|
50
|
+
lines.reject do |line|
|
|
51
|
+
skipping = true if line.start_with?("Pending:")
|
|
52
|
+
skipping = false if skipping && !line.empty? && !line.start_with?(" ", "Pending:")
|
|
53
|
+
skipping
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def short_frames(lines)
|
|
58
|
+
run = 0
|
|
59
|
+
lines.filter_map do |line|
|
|
60
|
+
run = line.match?(FRAME) ? run + 1 : 0
|
|
61
|
+
next line if run <= FRAMES
|
|
62
|
+
|
|
63
|
+
"\t ... more frames" if run == FRAMES + 1
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deflake
|
|
4
|
+
# Prints diagnoses in a form meant for people
|
|
5
|
+
class TextReport
|
|
6
|
+
VERDICTS = {
|
|
7
|
+
failed: "failed, could not run it alone",
|
|
8
|
+
fails_on_its_own: "fails on its own",
|
|
9
|
+
order_dependent: "fails only after other tests",
|
|
10
|
+
random: "fails at random, not after particular tests"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(out)
|
|
14
|
+
@out = out
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# shared: names more than one test of the file has, even tests left out of the report
|
|
18
|
+
def print(diagnoses, shared: [], **)
|
|
19
|
+
# Tests with the same name, such as RSpec examples from shared groups, get their ids too
|
|
20
|
+
@shared_names = diagnoses.map(&:name).tally.select { |_, count| count > 1 }.keys | shared
|
|
21
|
+
diagnoses.each { |diagnosis| print_diagnosis(diagnosis) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def print_diagnosis(diagnosis)
|
|
27
|
+
@out.puts "#{heading(diagnosis)}: #{outcome(diagnosis)}"
|
|
28
|
+
print_leaks(diagnosis.file_runs)
|
|
29
|
+
return if diagnosis.verdict == :stable
|
|
30
|
+
|
|
31
|
+
print_verdict(diagnosis)
|
|
32
|
+
print_culprits(diagnosis) if diagnosis.culprits
|
|
33
|
+
print_errors(diagnosis.file_runs)
|
|
34
|
+
print_errors(diagnosis.isolated_runs, "alone, ") if alone_errors?(diagnosis)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def heading(diagnosis)
|
|
38
|
+
@shared_names.include?(diagnosis.name) ? "#{diagnosis.name} (#{diagnosis.id})" : diagnosis.name
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def print_verdict(diagnosis)
|
|
42
|
+
verdict = VERDICTS.fetch(diagnosis.verdict)
|
|
43
|
+
return @out.puts(" verdict: #{verdict}") unless diagnosis.isolated_runs
|
|
44
|
+
|
|
45
|
+
@out.puts " verdict: #{verdict} (alone: #{failed(diagnosis.isolated_runs)})"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Errors outside of tests that only leaked failed in no run, which says nothing
|
|
49
|
+
def outcome(diagnosis)
|
|
50
|
+
runs = diagnosis.file_runs
|
|
51
|
+
return "none in #{runs.total} runs, but leaks" if diagnosis.id == Execution::OUTSIDE && runs.failures.zero?
|
|
52
|
+
|
|
53
|
+
failed(runs)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Runs where the test skipped itself are not counted, but said
|
|
57
|
+
def failed(summary)
|
|
58
|
+
return "skipped in all #{summary.skipped} runs" if summary.total.zero?
|
|
59
|
+
|
|
60
|
+
skipped = ", skipped in #{summary.skipped} more" if summary.skipped.positive?
|
|
61
|
+
"failed #{summary.failures} of #{summary.total} runs (#{percent(summary.failure_rate)})#{skipped}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A rare failure is not 0%, and a failure in all but a few runs is not 100%
|
|
65
|
+
def percent(rate)
|
|
66
|
+
return "<1%" if rate.positive? && rate < 0.005
|
|
67
|
+
return ">99%" if rate < 1 && rate >= 0.995
|
|
68
|
+
|
|
69
|
+
"#{(rate * 100).round}%"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def print_culprits(diagnosis)
|
|
73
|
+
culprits = diagnosis.culprits
|
|
74
|
+
@out.puts " #{culprit_line(culprits, diagnosis.verdict)}", " reproduce: #{culprits.command}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def culprit_line(culprits, verdict)
|
|
78
|
+
if culprits.alone then "fails with no other test before it with this seed"
|
|
79
|
+
elsif culprits.found? then "culprit: #{culprits.names.join(", ")}"
|
|
80
|
+
elsif culprits.hidden
|
|
81
|
+
"culprit: not found, tests in a class without a name ran too, and deflake cannot run them by name"
|
|
82
|
+
elsif verdict == :random then "culprit: none, the failure did not happen again with the same seed and order"
|
|
83
|
+
else "culprit: not found, the failure did not happen again with the same seed"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def print_leaks(summary)
|
|
88
|
+
summary.leaks.each do |leak, count|
|
|
89
|
+
@out.puts " leaks: #{leak} (#{count} of #{summary.total + summary.skipped} runs)"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Errors of the runs alone that the runs of the file did not have
|
|
94
|
+
def alone_errors?(diagnosis)
|
|
95
|
+
alone = diagnosis.isolated_runs
|
|
96
|
+
return false unless alone&.failures&.positive?
|
|
97
|
+
|
|
98
|
+
!(alone.errors.keys - diagnosis.file_runs.errors.keys).empty?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def print_errors(summary, prefix = "")
|
|
102
|
+
errors = summary.errors
|
|
103
|
+
errors = errors.except(*@printed) unless prefix.empty?
|
|
104
|
+
errors.each do |error, seeds|
|
|
105
|
+
@out.puts " #{prefix}seeds #{seeds.join(", ")}:", error.to_s.gsub(/^(?=.)/, " ")
|
|
106
|
+
end
|
|
107
|
+
@printed = errors.keys
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/deflake.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "deflake/version"
|
|
4
|
+
|
|
5
|
+
module Deflake
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# deflake's own code, added with -I to every test process it starts
|
|
9
|
+
LIB = __dir__
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require_relative "deflake/run"
|
|
13
|
+
require_relative "deflake/summary"
|
|
14
|
+
require_relative "deflake/result_file"
|
|
15
|
+
require_relative "deflake/tail"
|
|
16
|
+
require_relative "deflake/child"
|
|
17
|
+
require_relative "deflake/execution"
|
|
18
|
+
require_relative "deflake/culprits"
|
|
19
|
+
require_relative "deflake/bisect"
|
|
20
|
+
require_relative "deflake/adapters"
|
|
21
|
+
require_relative "deflake/runner"
|
|
22
|
+
require_relative "deflake/diagnosis"
|
|
23
|
+
require_relative "deflake/investigation"
|
|
24
|
+
require_relative "deflake/text_report"
|
|
25
|
+
require_relative "deflake/json_report"
|
|
26
|
+
require_relative "deflake/cli"
|
metadata
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: deflake
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Roman Belyaev
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
deflake runs a suspicious test many times, measures how often it fails,
|
|
14
|
+
tells apart failures that happen on their own from failures caused by test
|
|
15
|
+
order, finds the tests that cause them, and detects global state that tests
|
|
16
|
+
leave behind: ENV, the clock, globals, Rails.cache, Current attributes,
|
|
17
|
+
database rows and enqueued jobs.
|
|
18
|
+
email:
|
|
19
|
+
- slyrs@yandex.ru
|
|
20
|
+
executables:
|
|
21
|
+
- deflake
|
|
22
|
+
extensions: []
|
|
23
|
+
extra_rdoc_files: []
|
|
24
|
+
files:
|
|
25
|
+
- CHANGELOG.md
|
|
26
|
+
- LICENSE.txt
|
|
27
|
+
- README.md
|
|
28
|
+
- exe/deflake
|
|
29
|
+
- lib/deflake.rb
|
|
30
|
+
- lib/deflake/adapters.rb
|
|
31
|
+
- lib/deflake/adapters/minitest.rb
|
|
32
|
+
- lib/deflake/adapters/rspec.rb
|
|
33
|
+
- lib/deflake/bisect.rb
|
|
34
|
+
- lib/deflake/child.rb
|
|
35
|
+
- lib/deflake/cli.rb
|
|
36
|
+
- lib/deflake/culprits.rb
|
|
37
|
+
- lib/deflake/diagnosis.rb
|
|
38
|
+
- lib/deflake/execution.rb
|
|
39
|
+
- lib/deflake/investigation.rb
|
|
40
|
+
- lib/deflake/json_report.rb
|
|
41
|
+
- lib/deflake/leaks.rb
|
|
42
|
+
- lib/deflake/leaks/cache.rb
|
|
43
|
+
- lib/deflake/leaks/clock.rb
|
|
44
|
+
- lib/deflake/leaks/current.rb
|
|
45
|
+
- lib/deflake/leaks/env.rb
|
|
46
|
+
- lib/deflake/leaks/globals.rb
|
|
47
|
+
- lib/deflake/leaks/jobs.rb
|
|
48
|
+
- lib/deflake/leaks/rows.rb
|
|
49
|
+
- lib/deflake/minitest.rb
|
|
50
|
+
- lib/deflake/minitest_reporter.rb
|
|
51
|
+
- lib/deflake/minitest_tests.rb
|
|
52
|
+
- lib/deflake/outcome.rb
|
|
53
|
+
- lib/deflake/result_file.rb
|
|
54
|
+
- lib/deflake/rspec.rb
|
|
55
|
+
- lib/deflake/rspec_formatter.rb
|
|
56
|
+
- lib/deflake/run.rb
|
|
57
|
+
- lib/deflake/runner.rb
|
|
58
|
+
- lib/deflake/summary.rb
|
|
59
|
+
- lib/deflake/tail.rb
|
|
60
|
+
- lib/deflake/text_report.rb
|
|
61
|
+
- lib/deflake/version.rb
|
|
62
|
+
homepage: https://github.com/SlyDeath/ruby-deflake
|
|
63
|
+
licenses:
|
|
64
|
+
- MIT
|
|
65
|
+
metadata:
|
|
66
|
+
homepage_uri: https://github.com/SlyDeath/ruby-deflake
|
|
67
|
+
source_code_uri: https://github.com/SlyDeath/ruby-deflake
|
|
68
|
+
changelog_uri: https://github.com/SlyDeath/ruby-deflake/blob/main/CHANGELOG.md
|
|
69
|
+
bug_tracker_uri: https://github.com/SlyDeath/ruby-deflake/issues
|
|
70
|
+
rubygems_mfa_required: 'true'
|
|
71
|
+
rdoc_options: []
|
|
72
|
+
require_paths:
|
|
73
|
+
- lib
|
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '3.3'
|
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '0'
|
|
84
|
+
requirements: []
|
|
85
|
+
rubygems_version: 4.0.20
|
|
86
|
+
specification_version: 4
|
|
87
|
+
summary: Find flaky tests in Minitest and RSpec and explain why they fail.
|
|
88
|
+
test_files: []
|