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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ VERSION = "0.1.0"
5
+ 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: []