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.
@@ -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