minitest-mergify 2026.10.8
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/LICENSE +31 -0
- data/README.md +129 -0
- data/THIRD_PARTY_LICENSES +11060 -0
- data/lib/mergify/minitest/declarations.rb +41 -0
- data/lib/mergify/minitest/names.rb +128 -0
- data/lib/mergify/minitest/plugin.rb +106 -0
- data/lib/mergify/minitest/recorder.rb +208 -0
- data/lib/mergify/minitest/reruns.rb +97 -0
- data/lib/mergify/minitest/results.rb +65 -0
- data/lib/mergify/minitest/selection.rb +121 -0
- data/lib/mergify/minitest/test_run.rb +161 -0
- data/lib/mergify/minitest/version.rb +9 -0
- data/lib/mergify/minitest.rb +14 -0
- data/lib/minitest/mergify.rb +15 -0
- data/lib/minitest/mergify_plugin.rb +13 -0
- metadata +94 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mergify
|
|
4
|
+
module Minitest
|
|
5
|
+
# Where a spec test with no body is declared, which Minitest records
|
|
6
|
+
# nowhere. `it "does a thing"` without a block gets Minitest's own body, a
|
|
7
|
+
# proc in minitest/spec.rb that skips the test, so the test's method, its
|
|
8
|
+
# results' location and every name built on them would point into the
|
|
9
|
+
# Minitest gem, at a path that differs from one machine to the next. This
|
|
10
|
+
# gives it the same skip, declared at the `it`'s own line instead.
|
|
11
|
+
#
|
|
12
|
+
# Prepended to Minitest's spec DSL when the suite requires
|
|
13
|
+
# `minitest/mergify`, before its tests are defined; a test defined earlier
|
|
14
|
+
# keeps Minitest's body.
|
|
15
|
+
module Declarations
|
|
16
|
+
# The body Minitest gives such a test.
|
|
17
|
+
BODY = 'proc { skip "(no tests defined)" }'
|
|
18
|
+
|
|
19
|
+
def it(*args, &block)
|
|
20
|
+
body = block || Declarations.body_at(self, caller_locations(1, 1).first)
|
|
21
|
+
super(*args, &body)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# `specify` is an alias of Minitest's `it`, so it goes around this one.
|
|
25
|
+
def specify(*args, &block)
|
|
26
|
+
body = block || Declarations.body_at(self, caller_locations(1, 1).first)
|
|
27
|
+
super(*args, &body)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The body, compiled as if written at `site`: the point is that it
|
|
31
|
+
# reports the suite's file and line, not this one's.
|
|
32
|
+
def self.body_at(klass, site)
|
|
33
|
+
klass.class_eval(BODY, site.path, site.lineno)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.install
|
|
37
|
+
::Minitest::Spec::DSL.prepend(self) unless ::Minitest::Spec::DSL.ancestors.include?(self)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mergify/ci/core'
|
|
4
|
+
|
|
5
|
+
module Mergify
|
|
6
|
+
module Minitest
|
|
7
|
+
# The name Mergify knows each test by, with the file and the function its
|
|
8
|
+
# span reports.
|
|
9
|
+
#
|
|
10
|
+
# A class named by a constant cannot share its name with another one, so
|
|
11
|
+
# its tests are named as Minitest names them, `Class#method`. A class
|
|
12
|
+
# `describe` creates is named after its text, which two files can repeat,
|
|
13
|
+
# so its tests are named as rspec-mergify names its examples, by
|
|
14
|
+
# `CI::Core::TestNames`: `<file> > <describes> > <title>`. The title is
|
|
15
|
+
# the `it`'s text without the counter Minitest numbers it with, by its
|
|
16
|
+
# position in its `describe`: adding a test would otherwise rename every
|
|
17
|
+
# one below it. An `it` written without text is titled by its block's
|
|
18
|
+
# code. A title its `describe` repeats keeps the counter, so those tests
|
|
19
|
+
# stay apart.
|
|
20
|
+
#
|
|
21
|
+
# Each test also has a former name, the one a JUnit upload gave it:
|
|
22
|
+
# `Class.method`, as minitest-junit and minitest-reporters write the class
|
|
23
|
+
# and the method, and `mergify ci junit-process` joins them. Mergify's
|
|
24
|
+
# lists may still hold a test under it.
|
|
25
|
+
class Names
|
|
26
|
+
# `declared_in` is the file the test is declared in when its code is
|
|
27
|
+
# elsewhere, which leaves its line unknown; nil when they are the same.
|
|
28
|
+
Named = Struct.new(:name, :former, :function, :file, :declared_in, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
# What Minitest puts before an `it`'s text: its position in its
|
|
31
|
+
# `describe`, on four digits or more.
|
|
32
|
+
COUNTER = /\Atest_\d{4,}_/
|
|
33
|
+
|
|
34
|
+
# The text Minitest gives an `it` written without one.
|
|
35
|
+
ANONYMOUS = 'anonymous'
|
|
36
|
+
|
|
37
|
+
def initialize(runnables, root)
|
|
38
|
+
@root = root
|
|
39
|
+
@described = {}
|
|
40
|
+
runnables.each { |klass| index(klass) if described?(klass) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The test `method` of the class named `klass`, as a result carries
|
|
44
|
+
# them, its code in `file`.
|
|
45
|
+
def of(klass, method, file)
|
|
46
|
+
@described.fetch([klass, method, File.expand_path(file)]) do
|
|
47
|
+
Named.new(name: "#{klass}##{method}", former: "#{klass}.#{method}", function: method,
|
|
48
|
+
file: CI::Core::TestNames.relative(file, @root))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# A class `describe` created, rather than one a constant names.
|
|
55
|
+
def described?(klass)
|
|
56
|
+
klass.respond_to?(:desc) && !klass.desc.nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def index(klass)
|
|
60
|
+
chain = describe_chain(klass)
|
|
61
|
+
titles(klass).each do |method, title|
|
|
62
|
+
file = klass.instance_method(method).source_location&.first
|
|
63
|
+
next unless file
|
|
64
|
+
|
|
65
|
+
@described[[klass.name, method, File.expand_path(file)]] = described(klass, method, file, chain, title)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def described(klass, method, file, chain, title)
|
|
70
|
+
declared = declared_file(klass, file)
|
|
71
|
+
Named.new(name: CI::Core::TestNames.spec_name(declared, chain, title, @root),
|
|
72
|
+
former: "#{klass.name}.#{method}", function: title,
|
|
73
|
+
file: CI::Core::TestNames.relative(declared, @root),
|
|
74
|
+
declared_in: (File.expand_path(declared) unless declared == file))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The file a test of `klass` is declared in, given the one its code is
|
|
78
|
+
# in: the same, but for a test with no body that a Minitest 5 suite
|
|
79
|
+
# defined without requiring `minitest/mergify` first, whose code is
|
|
80
|
+
# Minitest's own (see Declarations). It is then taken from another test
|
|
81
|
+
# of its `describe`, when one has a body.
|
|
82
|
+
def declared_file(klass, file)
|
|
83
|
+
return file unless File.expand_path(file) == minitest_spec
|
|
84
|
+
|
|
85
|
+
klass.methods_matching(/\Atest_/)
|
|
86
|
+
.filter_map { |method| klass.instance_method(method).source_location&.first }
|
|
87
|
+
.find { |other| File.expand_path(other) != minitest_spec } || file
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def minitest_spec
|
|
91
|
+
@minitest_spec ||= File.expand_path(::Minitest::Spec::DSL.instance_method(:let).source_location.first)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Each test method's title, unless another test of the class has the
|
|
95
|
+
# same one.
|
|
96
|
+
def titles(klass)
|
|
97
|
+
titled = klass.methods_matching(/\Atest_/).to_h { |method| [method, title_of(klass, method)] }
|
|
98
|
+
repeated = titled.values.tally.select { |_, count| count > 1 }.keys
|
|
99
|
+
titled.to_h { |method, title| [method, repeated.include?(title) ? method : title] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Its text without the counter, or for an `it` written without one, the
|
|
103
|
+
# code of its block.
|
|
104
|
+
def title_of(klass, method)
|
|
105
|
+
text = method.sub(COUNTER, '')
|
|
106
|
+
return text unless text == ANONYMOUS
|
|
107
|
+
|
|
108
|
+
CI::Core::TestNames.block_text(klass.instance_method(method)) || text
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The texts of the `describe`s around the class's tests, outermost
|
|
112
|
+
# first, each as Minitest joined it into the class's name: a nested
|
|
113
|
+
# one's is its name without its parent's.
|
|
114
|
+
def describe_chain(klass)
|
|
115
|
+
parent = klass.superclass
|
|
116
|
+
return [*describe_chain(parent), own_text(klass, parent)] if described?(parent)
|
|
117
|
+
# A `describe` inside a class a constant names is named after it.
|
|
118
|
+
return [parent.name, own_text(klass, parent)] if klass.name.start_with?("#{parent.name}::")
|
|
119
|
+
|
|
120
|
+
[klass.name]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def own_text(klass, parent)
|
|
124
|
+
klass.name.delete_prefix("#{parent.name}::")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'minitest'
|
|
4
|
+
require 'mergify/ci/core'
|
|
5
|
+
require_relative '../minitest'
|
|
6
|
+
require_relative 'test_run'
|
|
7
|
+
require_relative 'names'
|
|
8
|
+
require_relative 'recorder'
|
|
9
|
+
require_relative 'reruns'
|
|
10
|
+
require_relative 'selection'
|
|
11
|
+
|
|
12
|
+
module Mergify
|
|
13
|
+
module Minitest
|
|
14
|
+
# What the plugin does once Minitest has parsed its options, before any
|
|
15
|
+
# test runs: start the run's session, hand it what the run collected,
|
|
16
|
+
# and wrap the reporter Minitest reports the run through.
|
|
17
|
+
module Plugin
|
|
18
|
+
# The reporters already wrapped: Minitest 5 initializes the plugin twice
|
|
19
|
+
# when the suite also requires it, and it is set up once.
|
|
20
|
+
@wrapped = ObjectSpace::WeakMap.new
|
|
21
|
+
|
|
22
|
+
# What each class runs, as the run's selection narrows it.
|
|
23
|
+
module Narrowing
|
|
24
|
+
def runnable_methods
|
|
25
|
+
selection = Plugin.selection
|
|
26
|
+
selection ? selection.narrow(self, super) : super
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The suites Minitest runs, once or in Fail First's two passes: the
|
|
31
|
+
# loop is `run_all_suites` in Minitest 6, `__run` in Minitest 5.
|
|
32
|
+
module Passes
|
|
33
|
+
if ::Minitest.respond_to?(:run_all_suites)
|
|
34
|
+
def run_all_suites(reporter, options)
|
|
35
|
+
Plugin.run_suites(reporter) { super }
|
|
36
|
+
end
|
|
37
|
+
else
|
|
38
|
+
def __run(reporter, options)
|
|
39
|
+
Plugin.run_suites(reporter) { super }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# The run's selection, once the session applied it; nil before, and
|
|
46
|
+
# in a run that does not report.
|
|
47
|
+
attr_reader :selection
|
|
48
|
+
|
|
49
|
+
def init(options)
|
|
50
|
+
reporter = ::Minitest.reporter
|
|
51
|
+
return if @wrapped.key?(reporter)
|
|
52
|
+
|
|
53
|
+
@wrapped[reporter] = true
|
|
54
|
+
test_run = (Mergify::Minitest.test_run ||= TestRun.new)
|
|
55
|
+
# Also when the native extension did not load, which nothing can
|
|
56
|
+
# tell apart from a run outside CI: the report then says why it
|
|
57
|
+
# reports nothing.
|
|
58
|
+
return unless test_run.reports? || !Mergify::CI::Core::Native.available?
|
|
59
|
+
|
|
60
|
+
start(reporter, test_run, options)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The run's suites, as the selection runs them, then flaky detection's
|
|
64
|
+
# reruns -- not after a stop Fail First made, whose whole point is to
|
|
65
|
+
# end the job sooner.
|
|
66
|
+
def run_suites(reporter, &)
|
|
67
|
+
return yield unless @selection
|
|
68
|
+
|
|
69
|
+
@selection.run(@recorder, &)
|
|
70
|
+
@reruns.run(reporter, @recorder) unless @recorder.stopping?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def start(reporter, test_run, options)
|
|
76
|
+
names = Names.new(::Minitest::Runnable.runnables, Dir.pwd)
|
|
77
|
+
# Collected as Minitest's filters leave the tests, before any
|
|
78
|
+
# selection narrows them.
|
|
79
|
+
@selection = nil
|
|
80
|
+
@selection = select(test_run, names, options)
|
|
81
|
+
@reruns = @selection && Reruns.new(test_run, @selection)
|
|
82
|
+
@recorder = Recorder.new(test_run, names, options[:io] || $stdout, @selection, @reruns)
|
|
83
|
+
@recorder.attach(reporter)
|
|
84
|
+
install
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Hand the session what the run collected, and apply what it decided.
|
|
88
|
+
# This runs on every CI run, opted in or not: a fault of this gem must
|
|
89
|
+
# cost the run its reduction, never its tests.
|
|
90
|
+
def select(test_run, names, options)
|
|
91
|
+
return unless test_run.reports?
|
|
92
|
+
|
|
93
|
+
Selection.collect(::Minitest::Runnable.runnables, names, options).tap { |selection| selection.apply(test_run) }
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
warn "Mergify Test Selection failed, so the full suite runs: #{e.class}: #{e.message}"
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def install
|
|
100
|
+
::Minitest::Test.singleton_class.prepend(Narrowing) unless ::Minitest::Test.singleton_class < Narrowing
|
|
101
|
+
::Minitest.singleton_class.prepend(Passes) unless ::Minitest.singleton_class < Passes
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mergify/ci/core'
|
|
4
|
+
require_relative 'names'
|
|
5
|
+
require_relative 'results'
|
|
6
|
+
|
|
7
|
+
module Mergify
|
|
8
|
+
module Minitest
|
|
9
|
+
# Hands each finished test to the session, for the run's trace, its
|
|
10
|
+
# verdict, the quarantine, Fail First and flaky detection, and prints what
|
|
11
|
+
# the session says once the run is over.
|
|
12
|
+
#
|
|
13
|
+
# It wraps the reporter Minitest sends every result through, rather than
|
|
14
|
+
# joining the reporters inside it: a plugin can replace those, and
|
|
15
|
+
# minitest-reporters does, dropping any added before it. Results recorded
|
|
16
|
+
# in another process (Rails' parallel workers) come back to this one.
|
|
17
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
18
|
+
class Recorder
|
|
19
|
+
HEADER = '# Mergify CI:'
|
|
20
|
+
|
|
21
|
+
# The ANSI colour of a line the session worded, by its severity; an
|
|
22
|
+
# `info` one keeps the terminal's.
|
|
23
|
+
SEVERITY_COLOURS = { 'warning' => 33, 'error' => 31 }.freeze
|
|
24
|
+
|
|
25
|
+
# Each test's first attempt, by name: how it ended (`'passed'`,
|
|
26
|
+
# `'failed'` or `'skipped'`, before the quarantine absorbed a failure)
|
|
27
|
+
# and how long it took, in milliseconds, for flaky detection.
|
|
28
|
+
attr_reader :first_attempts
|
|
29
|
+
|
|
30
|
+
# `selection` is the run's, when it asked Test Selection; nil when it
|
|
31
|
+
# does not report. `reruns`, flaky detection's, takes the results of the
|
|
32
|
+
# tests it reruns.
|
|
33
|
+
def initialize(test_run, names, io, selection, reruns)
|
|
34
|
+
@test_run = test_run
|
|
35
|
+
@names = names
|
|
36
|
+
@io = io
|
|
37
|
+
@selection = selection
|
|
38
|
+
@reruns = reruns
|
|
39
|
+
@fail_first = selection && test_run.fail_first
|
|
40
|
+
@first_attempts = {}
|
|
41
|
+
@lock = Mutex.new
|
|
42
|
+
@changed = ConditionVariable.new
|
|
43
|
+
@stopping = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Wrap `reporter`, the one Minitest reports the run through.
|
|
47
|
+
def attach(reporter)
|
|
48
|
+
reporter.singleton_class.prepend(hooks)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Before Minitest's own reporters count the result: a failure the
|
|
52
|
+
# quarantine absorbs reaches them as a skip, so it fails nothing, and
|
|
53
|
+
# Mergify still sees it fail.
|
|
54
|
+
def record(result)
|
|
55
|
+
return unless @test_run.reports?
|
|
56
|
+
|
|
57
|
+
named = named(result)
|
|
58
|
+
status = Results.status_of(result)
|
|
59
|
+
absorbed = trace(result, named, status)
|
|
60
|
+
conclude(named.name, status, absorbed, result.time)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Whether `result` is a rerun's, which flaky detection takes as an
|
|
64
|
+
# attempt and Minitest's own reporters never see.
|
|
65
|
+
def rerun_taken?(result)
|
|
66
|
+
return false unless @reruns&.rerun?(result.klass, result.name)
|
|
67
|
+
|
|
68
|
+
@reruns.take(result, named(result).name)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Whether Minitest is about to report a rerun of `method` of `klass` (a
|
|
72
|
+
# class, or what Rails' workers report its name as).
|
|
73
|
+
def rerun?(klass, method)
|
|
74
|
+
@reruns&.rerun?(klass.respond_to?(:name) ? klass.name : klass.to_s, method) || false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Whether flaky detection found a new test flaky, in reruns Minitest's
|
|
78
|
+
# own results never saw: it fails the run.
|
|
79
|
+
def failed_by_reruns?
|
|
80
|
+
@test_run.reports? && @test_run.flaky_detection_fails_the_run?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Wait until every test named `names` was recorded: a pass of Fail
|
|
84
|
+
# First ran in Minitest's threads or Rails' workers hands its results
|
|
85
|
+
# back after its suites were handed over.
|
|
86
|
+
def wait_for(names)
|
|
87
|
+
@lock.synchronize { @changed.wait(@lock) until names.all? { |name| @first_attempts.key?(name) } }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Whether Fail First said to stop the run, once every test it put first
|
|
91
|
+
# was recorded.
|
|
92
|
+
def stopping?
|
|
93
|
+
@lock.synchronize { @stopping }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Once Minitest has reported the run: the verdict, then the trace, then
|
|
97
|
+
# the report under its own header, after Minitest's summary. The
|
|
98
|
+
# verdict first, on purpose: it is what the next merge-queue rerun of
|
|
99
|
+
# this job is answered from, and it must never wait behind the trace
|
|
100
|
+
# upload's timeout and retries.
|
|
101
|
+
def finish(passed:)
|
|
102
|
+
report_verdict if @selection
|
|
103
|
+
@test_run.report_trace(failed: !passed)
|
|
104
|
+
print_report
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# The test's span, as the quarantine accounts for it, and the failure
|
|
110
|
+
# it absorbs: whether it did.
|
|
111
|
+
def trace(result, named, status)
|
|
112
|
+
recorded = @test_run.record_quarantine([named.name, named.former], status)
|
|
113
|
+
@test_run.record_case(Results.case_of(result, named, recorded['quarantined']))
|
|
114
|
+
Results.absorb(result) if recorded['absorbed']
|
|
115
|
+
recorded['absorbed']
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def named(result)
|
|
119
|
+
@names.of(result.klass, result.name, result.source_location.first)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The test's first attempt, as it ended, for flaky detection, and its
|
|
123
|
+
# final status, for the verdict and Fail First: a failure the quarantine
|
|
124
|
+
# absorbed is none.
|
|
125
|
+
def conclude(name, status, absorbed, time)
|
|
126
|
+
final = absorbed ? 'quarantined_failed' : status
|
|
127
|
+
@test_run.record_test(name, final, time)
|
|
128
|
+
stop = @fail_first&.unit_done(name, final == 'failed')
|
|
129
|
+
@lock.synchronize do
|
|
130
|
+
@first_attempts[name] ||= [status, time * 1000]
|
|
131
|
+
@stopping ||= stop
|
|
132
|
+
@changed.broadcast
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# How the run ended: how many of the tests it was to run never ran --
|
|
137
|
+
# the ones Fail First's stop left out, a run Minitest was interrupted
|
|
138
|
+
# in -- and whether Fail First stopped it.
|
|
139
|
+
def report_verdict
|
|
140
|
+
not_run = [@selection.to_run - @test_run.executed_count, 0].max
|
|
141
|
+
@test_run.report_verdict(not_run: not_run, told_to_stop: stopping?)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# What the wrapped reporter does before its own `record` and after its
|
|
145
|
+
# own `report`.
|
|
146
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
147
|
+
def hooks
|
|
148
|
+
recorder = self
|
|
149
|
+
Module.new do
|
|
150
|
+
define_method(:prerecord) do |klass, method|
|
|
151
|
+
super(klass, method) unless recorder.rerun?(klass, method)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
define_method(:record) do |result|
|
|
155
|
+
next if recorder.rerun_taken?(result)
|
|
156
|
+
|
|
157
|
+
recorder.record(result)
|
|
158
|
+
super(result)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
define_method(:passed?) do
|
|
162
|
+
super() && !recorder.failed_by_reruns?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
define_method(:report) do
|
|
166
|
+
super()
|
|
167
|
+
recorder.finish(passed: passed?)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# What the session says once the run is over, then what only this gem
|
|
173
|
+
# says, under a header in Minitest's style (`# Running:`); none at all
|
|
174
|
+
# when neither has anything to say.
|
|
175
|
+
def print_report
|
|
176
|
+
lines = report_lines
|
|
177
|
+
return if lines.empty?
|
|
178
|
+
|
|
179
|
+
@io.puts "\n#{HEADER}\n\n"
|
|
180
|
+
lines.each { |line| @io.puts line }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The session's lines, each as it is in the colour of its severity, then
|
|
184
|
+
# this gem's own, after a blank line.
|
|
185
|
+
def report_lines
|
|
186
|
+
lines = @test_run.summary.map { |severity, text| in_severity_colour(text, severity) }
|
|
187
|
+
own = Mergify::CI::Core::Native.available? ? [] : [native_warning]
|
|
188
|
+
lines.any? && own.any? ? [*lines, '', *own] : [*lines, *own]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The extension is what detects CI, so when it did not load there is no
|
|
192
|
+
# repository name either: say what actually happened instead.
|
|
193
|
+
def native_warning
|
|
194
|
+
'WARNING: the Mergify native extension could not be loaded, so this run reports nothing: ' \
|
|
195
|
+
"#{Mergify::CI::Core::Native.load_error&.message}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# `text` in the colour of `severity` when Minitest writes to a terminal;
|
|
199
|
+
# as it is otherwise, for an `info` one, and for a blank one.
|
|
200
|
+
def in_severity_colour(text, severity)
|
|
201
|
+
colour = SEVERITY_COLOURS[severity]
|
|
202
|
+
return text unless colour && !text.empty? && @io.respond_to?(:tty?) && @io.tty?
|
|
203
|
+
|
|
204
|
+
"\e[#{colour}m#{text}\e[0m"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'minitest'
|
|
4
|
+
require_relative 'results'
|
|
5
|
+
|
|
6
|
+
module Mergify
|
|
7
|
+
module Minitest
|
|
8
|
+
# Flaky detection, once the run's tests are over: the session plans how
|
|
9
|
+
# many times to rerun the tests it targets, from their first attempts, and
|
|
10
|
+
# each rerun goes through Minitest's own dispatch, so it runs where the
|
|
11
|
+
# test ran -- inline, in Minitest's threads, or in Rails' workers -- and
|
|
12
|
+
# its result comes back to the reporter Minitest records results through.
|
|
13
|
+
# There, each is an attempt for the session, which Minitest's own
|
|
14
|
+
# reporters never see.
|
|
15
|
+
class Reruns
|
|
16
|
+
def initialize(test_run, selection)
|
|
17
|
+
@test_run = test_run
|
|
18
|
+
@selection = selection
|
|
19
|
+
@pending = Hash.new(0)
|
|
20
|
+
@lock = Mutex.new
|
|
21
|
+
@changed = ConditionVariable.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Rerun what flaky detection targets, once `recorder` recorded every test
|
|
25
|
+
# the run was to run: a run in Minitest's threads or Rails' workers
|
|
26
|
+
# hands its results back after its suites were handed over.
|
|
27
|
+
def run(reporter, recorder)
|
|
28
|
+
return unless @test_run.flaky_detection_runs?
|
|
29
|
+
|
|
30
|
+
recorder.wait_for(@selection.names_to_run)
|
|
31
|
+
reruns = prepare(recorder.first_attempts)
|
|
32
|
+
plan = reruns&.shared_repeats
|
|
33
|
+
rerun(plan, reporter) if plan
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether Minitest is about to report a rerun of `method` of the class
|
|
37
|
+
# named `klass`, which the reporter keeps from Minitest's own.
|
|
38
|
+
def rerun?(klass, method)
|
|
39
|
+
@lock.synchronize { @pending[[klass, method]].positive? }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The result of a rerun of the test named `name`, as an attempt: whether
|
|
43
|
+
# it was one.
|
|
44
|
+
def take(result, name)
|
|
45
|
+
@lock.synchronize do
|
|
46
|
+
key = [result.klass, result.name]
|
|
47
|
+
return false unless @pending[key].positive?
|
|
48
|
+
|
|
49
|
+
@test_run.record_attempt(name, Results.status_of(result), result.time * 1000)
|
|
50
|
+
@pending[key] -= 1
|
|
51
|
+
@changed.broadcast
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# The session's reruns for every test the run ran, each known by its
|
|
59
|
+
# former name too, with the first attempt of each test flaky detection
|
|
60
|
+
# targets, as it passed or failed.
|
|
61
|
+
def prepare(first_attempts)
|
|
62
|
+
former_names = @selection.tests.to_h { |test| [test.name, ["#{test.klass.name}.#{test.method_name}"]] }
|
|
63
|
+
reruns = @test_run.prepare_reruns(first_attempts.keys, former_names)
|
|
64
|
+
first_attempts.each do |name, (outcome, duration_ms)|
|
|
65
|
+
next unless reruns&.tracks?(name) && %w[passed failed].include?(outcome)
|
|
66
|
+
|
|
67
|
+
@test_run.record_attempt(name, outcome, duration_ms)
|
|
68
|
+
end
|
|
69
|
+
reruns
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Each test of `plan` again, as many times as it says, round after
|
|
73
|
+
# round, then wait for every result.
|
|
74
|
+
def rerun(plan, reporter)
|
|
75
|
+
tests = @selection.tests.select { |test| plan['tests'].include?(test.name) }
|
|
76
|
+
expect(tests, plan['repeats'])
|
|
77
|
+
plan['repeats'].times { tests.each { |test| dispatch(test, reporter) } }
|
|
78
|
+
@lock.synchronize { @changed.wait(@lock) until @pending.values.all?(&:zero?) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Each of `tests` is reported `repeats` more times.
|
|
82
|
+
def expect(tests, repeats)
|
|
83
|
+
@lock.synchronize { tests.each { |test| @pending[[test.klass.name, test.method_name]] += repeats } }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# As Minitest runs a test of a class: inline and reported, or handed to
|
|
87
|
+
# its parallel executor, which reports it once it ran.
|
|
88
|
+
def dispatch(test, reporter)
|
|
89
|
+
if ::Minitest::Runnable.respond_to?(:run_suite)
|
|
90
|
+
test.klass.run(test.klass, test.method_name, reporter)
|
|
91
|
+
else
|
|
92
|
+
test.klass.run_one_method(test.klass, test.method_name, reporter)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mergify
|
|
4
|
+
module Minitest
|
|
5
|
+
# What a Minitest result says, as the session takes it.
|
|
6
|
+
module Results
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Why a quarantined failure reads as a skip, as Minitest's verbose
|
|
10
|
+
# output shows it.
|
|
11
|
+
QUARANTINE_MESSAGE = 'Test is quarantined from Mergify Test Insights'
|
|
12
|
+
|
|
13
|
+
# The test, as the session takes it once it finished, before the
|
|
14
|
+
# quarantine absorbed its failure. A result knows how long the test
|
|
15
|
+
# took, not when it started: Rails' parallel workers hand it over after
|
|
16
|
+
# the fact. A test declared away from its code is reported where it is
|
|
17
|
+
# declared, with no line.
|
|
18
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
19
|
+
def case_of(result, named, quarantined)
|
|
20
|
+
file, line = named.declared_in ? [named.declared_in, nil] : result.source_location
|
|
21
|
+
ended = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
22
|
+
{
|
|
23
|
+
'name' => named.name,
|
|
24
|
+
'file' => named.file,
|
|
25
|
+
'function' => named.function,
|
|
26
|
+
'start_unix_nano' => ended - (result.time * 1_000_000_000).round,
|
|
27
|
+
'end_unix_nano' => ended,
|
|
28
|
+
'status' => status_of(result),
|
|
29
|
+
'exception' => exception_of(result),
|
|
30
|
+
'quarantined' => quarantined,
|
|
31
|
+
'namespace' => result.klass,
|
|
32
|
+
'line' => line&.positive? ? line : nil,
|
|
33
|
+
'path' => File.expand_path(file)
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The failure the quarantine absorbed, as a skip that keeps where it was
|
|
38
|
+
# raised.
|
|
39
|
+
def absorb(result)
|
|
40
|
+
failure = result.failures.find { |raised| !raised.is_a?(::Minitest::Skip) }
|
|
41
|
+
skip = ::Minitest::Skip.new(QUARANTINE_MESSAGE)
|
|
42
|
+
skip.set_backtrace(failure&.backtrace || [])
|
|
43
|
+
result.failures = [skip]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A skip is a failure Minitest reports apart, so it comes first.
|
|
47
|
+
def status_of(result)
|
|
48
|
+
return 'skipped' if result.skipped?
|
|
49
|
+
|
|
50
|
+
result.passed? ? 'passed' : 'failed'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# What the test raised: the first of its failures, the error behind an
|
|
54
|
+
# unexpected one rather than Minitest's wrapper.
|
|
55
|
+
def exception_of(result)
|
|
56
|
+
return if result.passed? || result.skipped?
|
|
57
|
+
|
|
58
|
+
failure = result.failures.find { |raised| !raised.is_a?(::Minitest::Skip) }
|
|
59
|
+
error = failure.respond_to?(:error) ? failure.error : failure
|
|
60
|
+
{ 'type' => error.class.name, 'message' => error.message,
|
|
61
|
+
'stacktrace' => (error.backtrace || []).join("\n") }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|