rspec_trunk_flaky_tests 0.15.6-x86_64-linux-gnu → 0.16.0.pre.beta.1-x86_64-linux-gnu

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f311c33fb02453903e4ae41d750dfdfc847c30a819eb17e91effa65e3f9b1f89
4
- data.tar.gz: 3e76f359ac2004a78b40331aeee873824aa6f3985d123472dcd6931041d016a9
3
+ metadata.gz: 7b52faa9ecd5bfa262776ef4d8f73de033dd4a891daf3ffc31153323b7a5c0cf
4
+ data.tar.gz: 4a9fef8855bfce5c976efbfcf55056d49166cf61da0fa7ef1d51123b2db6a44b
5
5
  SHA512:
6
- metadata.gz: bcfe4e0f722687a9379e0365f067114d6208d06ee24e349425c9b10df57f026c2701274caeb70f544e22433775b3a8c6a01a013792c1e9f569496bd4e91ccd69
7
- data.tar.gz: 026b982e02a44d14bf7e888da13d8404ef96fd76f811c882bca95cdb06ad11e0893c5583502910f5ac8b605aa5cd44c33892352b4957cf976f267a43c8fd4960
6
+ metadata.gz: 67dffdb5d3484cec95d2604776ed03a227e0a540f034e48b6183cdc0480fe5e3b6f917eb0d74a84b7763bee240280e31c40159801b18e67a0f006416d89a1cf1
7
+ data.tar.gz: ba96e766399fb7e6944f2f9deb9659e584409757de75e07da0dedd821e62767790883a2335c45dddca129cf0d34424455a732a268e08883279ecd03020102aae
@@ -37,407 +37,438 @@ require 'rspec/core/formatters/exception_presenter'
37
37
  require 'time'
38
38
  require 'rspec_trunk_flaky_tests'
39
39
 
40
- # trunk-ignore-all(rubocop/Style/GlobalVars)
40
+ module RSpec
41
+ # Trunk holds everything this gem defines (the native extension defines the
42
+ # module and its classes: TestReport, Status, CIInfo, ...). Nothing is added to
43
+ # the global namespace or to core classes; test/namespace_spec.rb enforces that.
44
+ module Trunk
45
+ ANSI_ESCAPE_PATTERN = %r{(?:\e[@-Z\\-_]|\e\[[0-?]*[ -/]*[@-~])}
46
+
47
+ # Knapsack example detector instantiates all test cases in order to determine how to shard them
48
+ # These instantiations should not generate test bundles, so we
49
+ # disable the gem when running under knapsack_pro:rspec_test_example_detector
50
+ KNAPSACK_DETECTOR_COMMANDS = %w[
51
+ knapsack_pro:rspec_test_example_detector
52
+ knapsack_pro:queue:rspec:initialize
53
+ ].freeze
54
+
55
+ # Example metadata keys this gem sets, each mapped to the unprefixed name it
56
+ # used to be set under. The old names are deprecated: they are still written
57
+ # (see .write_metadata) so existing readers keep working, but will be removed
58
+ # in a future release.
59
+ DEPRECATED_METADATA_KEYS = {
60
+ trunk_quarantined_exception: :quarantined_exception,
61
+ trunk_attempt_number: :attempt_number,
62
+ trunk_is_description_generated: :is_description_generated
63
+ }.freeze
64
+
65
+ class << self
66
+ # The state for this RSpec process, or nil when Trunk is disabled.
67
+ attr_reader :current_run
68
+
69
+ def setup
70
+ return if current_run || disabled?
71
+
72
+ run = @current_run = Run.new
73
+ RSpec::Core::Example.prepend(ExampleExtension)
74
+ RSpec.configure do |config|
75
+ config.before(:example) do
76
+ skip('Quarantine lookup failed, skipping test run') if run.abort_remaining?
77
+ end
78
+ config.around(:each) { |example| Trunk.run_counting_attempts(example) }
79
+ config.reporter.register_listener AnalyticsListener.new(run), :example_finished, :close
80
+ end
81
+ end
41
82
 
42
- # String is an override to the main String class that is used to colorize the output
43
- # it is used to make the output more readable
44
- class String
45
- def colorize(color_code)
46
- "\e[#{color_code}m#{self}\e[0m"
47
- end
83
+ def disabled?
84
+ knapsack_detector_mode? || ENV['DISABLE_RSPEC_TRUNK_FLAKY_TESTS'] == 'true' ||
85
+ ENV['TRUNK_ORG_URL_SLUG'].nil? || ENV['TRUNK_API_TOKEN'].nil?
86
+ end
48
87
 
49
- def red
50
- colorize(31)
51
- end
88
+ def knapsack_detector_mode?
89
+ KNAPSACK_DETECTOR_COMMANDS.any? { |command| command_line.include?(command) }
90
+ end
52
91
 
53
- def green
54
- colorize(32)
55
- end
92
+ def quarantine_query_failure_exit?
93
+ ENV['TRUNK_QUARANTINE_QUERY_FAILURE_EXIT'] == 'true'
94
+ end
56
95
 
57
- def yellow
58
- colorize(33)
59
- end
60
- end
96
+ def command_line
97
+ "#{$PROGRAM_NAME} #{ARGV.join(' ')}"
98
+ end
61
99
 
62
- ANSI_ESCAPE_PATTERN = %r{(?:\e[@-Z\\-_]|\e\[[0-?]*[ -/]*[@-~])}
100
+ def run_counting_attempts(example)
101
+ example.run
102
+ # monitor attempts in the metadata
103
+ attempt_number = example.metadata[:trunk_attempt_number]
104
+ write_metadata(example.metadata, :trunk_attempt_number, attempt_number ? attempt_number + 1 : 0)
105
+ end
63
106
 
64
- def escape(str)
65
- str.dump[1..-2]
66
- end
107
+ # Sets a trunk_ metadata key, and its deprecated unprefixed name too.
108
+ def write_metadata(metadata, key, value)
109
+ metadata[key] = value
110
+ deprecated_key = DEPRECATED_METADATA_KEYS[key]
111
+ metadata[deprecated_key] = value if deprecated_key
112
+ end
67
113
 
68
- def strip_ansi_codes(text)
69
- text.to_s.gsub(ANSI_ESCAPE_PATTERN, '')
70
- end
114
+ def escape(str)
115
+ str.dump[1..-2]
116
+ end
71
117
 
72
- # Knapsack example detector instantiates all test cases in order to determine how to shard them
73
- # These instantiations should not generate test bundles, so we
74
- # disable the gem when running under knapsack_pro:rspec_test_example_detector
75
- def knapsack_detector_mode?
76
- knapsack_detector_command?
77
- end
118
+ def strip_ansi_codes(text)
119
+ text.to_s.gsub(ANSI_ESCAPE_PATTERN, '')
120
+ end
78
121
 
79
- def knapsack_detector_command?
80
- command_line = "#{$PROGRAM_NAME} #{ARGV.join(' ')}".strip
81
- command_line.include?('knapsack_pro:rspec_test_example_detector') ||
82
- command_line.include?('knapsack_pro:queue:rspec:initialize')
83
- end
122
+ # The escaped file path and the dotted classname derived from it.
123
+ def file_and_classname(example)
124
+ file = escape(example.metadata[:file_path])
125
+ [file, file.sub(%r{\.[^/.]+\Z}, '').gsub('/', '.').gsub(/\A\.+|\.+\Z/, '')]
126
+ end
84
127
 
85
- def trunk_disabled
86
- knapsack_detector_mode? || ENV['DISABLE_RSPEC_TRUNK_FLAKY_TESTS'] == 'true' ||
87
- ENV['TRUNK_ORG_URL_SLUG'].nil? || ENV['TRUNK_API_TOKEN'].nil?
88
- end
128
+ def parent_name(example)
129
+ name = example.example_group.metadata[:description]
130
+ name.empty? ? 'rspec' : name
131
+ end
132
+ end
89
133
 
90
- def quarantine_query_failure_exit?
91
- ENV['TRUNK_QUARANTINE_QUERY_FAILURE_EXIT'] == 'true'
92
- end
134
+ # ANSI colors for the gem's console output, without patching String.
135
+ module Colors
136
+ module_function
93
137
 
94
- # we want to cache the test report in memory so we can add to it as we go and reduce the number of API calls
95
- $test_report = TestReport.new('rspec', "#{$PROGRAM_NAME} #{ARGV.join(' ')}", nil)
96
- $failure_encountered_and_quarantining_disabled = false
97
- $failure_encountered_and_quarantine_lookup_failed = false
138
+ def colorize(text, color_code)
139
+ "\e[#{color_code}m#{text}\e[0m"
140
+ end
98
141
 
99
- module RSpec
100
- module Core
101
- # Example is the class that represents a test case
102
- class Example
103
- # keep the original method around so we can call it
104
- alias set_exception_core set_exception
105
- alias assign_generated_description_core assign_generated_description
106
- # RSpec uses the existance of an exception to determine if the test failed
107
- # We need to override this to allow us to capture the exception and then
108
- # decide if we want to fail the test or not
109
- # trunk-ignore(rubocop/Naming/AccessorMethodName)
110
- def set_exception(exception)
111
- # Pending stays green, except a fixed pending example is a real failure.
112
- return set_exception_core(exception) if metadata[:pending] && !pending_example_fixed?(exception)
113
- return set_exception_core(exception) if trunk_disabled
114
- return set_exception_core(exception) if metadata[:retry_attempts]&.positive?
142
+ def red(text)
143
+ colorize(text, 31)
144
+ end
115
145
 
116
- handle_quarantine_check_safely(exception)
146
+ def green(text)
147
+ colorize(text, 32)
117
148
  end
118
149
 
119
- # If the quarantine machinery itself blows up, the failure must stand.
120
- def handle_quarantine_check_safely(exception)
121
- handle_quarantine_check(exception)
122
- rescue StandardError => e
123
- puts "Quarantine check errored (#{e.class}: #{e.message}), treating test as not quarantined".yellow
124
- set_exception_core(exception)
150
+ def yellow(text)
151
+ colorize(text, 33)
125
152
  end
153
+ end
126
154
 
127
- def pending_example_fixed?(exception)
128
- defined?(RSpec::Core::Pending::PendingExampleFixedError) &&
129
- exception.is_a?(RSpec::Core::Pending::PendingExampleFixedError)
155
+ # Run is the state shared by every example in this RSpec process: the test
156
+ # report (cached in memory so we can add to it as we go and reduce the number
157
+ # of API calls), and whether quarantining turned out to be unavailable, so
158
+ # that's only announced once.
159
+ class Run
160
+ attr_reader :report
161
+
162
+ def initialize(report = TestReport.new('rspec', Trunk.command_line, nil))
163
+ @report = report
164
+ @quarantining_disabled = false
165
+ @lookup_failed = false
166
+ end
167
+
168
+ def quarantining_disabled?
169
+ @quarantining_disabled
170
+ end
171
+
172
+ def lookup_failed?
173
+ @lookup_failed
174
+ end
175
+
176
+ def abort_remaining?
177
+ lookup_failed? && Trunk.quarantine_query_failure_exit?
178
+ end
179
+
180
+ # Whether a failure of `example` with `exception` is quarantined. If the
181
+ # quarantine machinery itself blows up, the failure must stand.
182
+ def quarantine?(example, exception)
183
+ check_quarantine(example, exception)
184
+ rescue StandardError => e
185
+ puts Colors.yellow("Quarantine check errored (#{e.class}: #{e.message}), treating test as not quarantined")
186
+ false
130
187
  end
131
188
 
189
+ private
190
+
132
191
  # trunk-ignore(rubocop/Metrics/AbcSize,rubocop/Metrics/MethodLength,rubocop/Metrics/CyclomaticComplexity)
133
- def handle_quarantine_check(exception)
134
- id = generate_trunk_id
135
- name = full_description
136
- parent_name = example_group.metadata[:description]
137
- parent_name = parent_name.empty? ? 'rspec' : parent_name
138
- file = escape(metadata[:file_path])
139
- classname = file.sub(%r{\.[^/.]+\Z}, '').gsub('/', '.').gsub(/\A\.+|\.+\Z/, '')
140
- unless $failure_encountered_and_quarantining_disabled
141
- puts "Test failed, checking if it can be quarantined: `#{location}`".yellow
192
+ def check_quarantine(example, exception)
193
+ file, classname = Trunk.file_and_classname(example)
194
+ unless quarantining_disabled?
195
+ puts Colors.yellow("Test failed, checking if it can be quarantined: `#{example.location}`")
142
196
  end
143
- is_quarantined_result = $test_report.is_quarantined(id, name, parent_name, classname, file)
197
+ result = report.is_quarantined(example.trunk_id, example.full_description, Trunk.parent_name(example),
198
+ classname, file)
144
199
 
145
- if is_quarantined_result.quarantine_lookup_failed
146
- unless $failure_encountered_and_quarantine_lookup_failed
147
- puts 'Failed to check quarantining status, no failures will be quarantined'.yellow
148
- $failure_encountered_and_quarantine_lookup_failed = true
200
+ if result.quarantine_lookup_failed
201
+ unless lookup_failed?
202
+ puts Colors.yellow('Failed to check quarantining status, no failures will be quarantined')
203
+ @lookup_failed = true
149
204
  end
150
- if quarantine_query_failure_exit?
151
- puts 'Quarantine lookup failed, exiting early'.red
205
+ if Trunk.quarantine_query_failure_exit?
206
+ puts Colors.red('Quarantine lookup failed, exiting early')
152
207
  RSpec.world.wants_to_quit = true
153
208
  end
154
- set_exception_core(exception)
155
- elsif is_quarantined_result.quarantining_disabled_for_repo
156
- unless $failure_encountered_and_quarantining_disabled
157
- puts 'Quarantining is disabled for this repo, no failures will be quarantined'.yellow
158
- $failure_encountered_and_quarantining_disabled = true
209
+ false
210
+ elsif result.quarantining_disabled_for_repo
211
+ unless quarantining_disabled?
212
+ puts Colors.yellow('Quarantining is disabled for this repo, no failures will be quarantined')
213
+ @quarantining_disabled = true
159
214
  end
160
- set_exception_core(exception)
161
- elsif is_quarantined_result.test_is_quarantined
215
+ false
216
+ elsif result.test_is_quarantined
162
217
  # monitor the override in the metadata
163
- metadata[:quarantined_exception] = exception
164
- puts "Test is quarantined, overriding exception: #{exception}".green
165
- nil
218
+ Trunk.write_metadata(example.metadata, :trunk_quarantined_exception, exception)
219
+ puts Colors.green("Test is quarantined, overriding exception: #{exception}")
220
+ true
166
221
  else
167
- puts 'Test is not quarantined, continuing'.red
168
- set_exception_core(exception)
222
+ puts Colors.red('Test is not quarantined, continuing')
223
+ false
169
224
  end
170
225
  end
226
+ end
171
227
 
172
- def assign_generated_description
173
- metadata[:is_description_generated] = description_generated?
174
- assign_generated_description_core
175
- end
176
-
177
- def description_generated?
178
- return metadata[:is_description_generated] unless metadata[:is_description_generated].nil?
228
+ # Prepended to RSpec::Core::Example. RSpec uses the existence of an exception
229
+ # to determine if the test failed, so #set_exception is where we capture the
230
+ # exception and decide whether to fail the test or not.
231
+ module ExampleExtension
232
+ # trunk-ignore(rubocop/Naming/AccessorMethodName)
233
+ def set_exception(exception)
234
+ return super unless quarantine_candidate?(exception)
235
+ return super unless Trunk.current_run&.quarantine?(self, exception)
179
236
 
180
- description == location_description
237
+ nil
181
238
  end
182
239
 
183
- def generate_trunk_id
184
- return "trunk:#{id}-#{location}" if description_generated?
240
+ def trunk_id
241
+ "trunk:#{id}-#{location}" if trunk_description_generated?
185
242
  end
186
243
 
187
- # Procsy is a class that is used to wrap execution of the Example class
188
- class Procsy
189
- def run_with_trunk
190
- RSpec::Trunk.new(self).run
191
- end
192
- end
193
- end
194
- end
195
- end
244
+ private
196
245
 
197
- module RSpec
198
- # Trunk is a class that is used to monitor the execution of the Example class
199
- class Trunk
200
- def self.setup
201
- RSpec.configure do |config|
202
- if trunk_disabled
203
- config.around(:each, &:run)
204
- else
205
- config.around(:each, &:run_with_trunk)
206
- end
246
+ def assign_generated_description
247
+ Trunk.write_metadata(metadata, :trunk_is_description_generated, trunk_description_generated?)
248
+ super
207
249
  end
208
- end
209
-
210
- def initialize(example)
211
- @example = example
212
- end
213
250
 
214
- def current_example
215
- @current_example ||= RSpec.current_example
216
- end
251
+ def trunk_description_generated?
252
+ cached = metadata[:trunk_is_description_generated]
253
+ return cached unless cached.nil?
217
254
 
218
- def run
219
- # run the test
220
- @example.run
221
- # monitor attempts in the metadata
222
- if @example.metadata[:attempt_number]
223
- @example.metadata[:attempt_number] += 1
224
- else
225
- @example.metadata[:attempt_number] = 0
255
+ description == location_description
226
256
  end
227
- end
228
- end
229
- end
230
257
 
231
- # PlainColorizer is a no-op colorizer passed to RSpec's ExceptionPresenter so the
232
- # formatted output is plain text suitable for storage and the web UI.
233
- module PlainColorizer
234
- module_function
258
+ def quarantine_candidate?(exception)
259
+ # Pending stays green, except a fixed pending example is a real failure.
260
+ return false if metadata[:pending] && !pending_example_fixed?(exception)
235
261
 
236
- def wrap(text, _code_or_symbol)
237
- text
238
- end
239
- end
262
+ !metadata[:retry_attempts]&.positive?
263
+ end
240
264
 
241
- # Defer to RSpec's own ExceptionPresenter so the failure_message field matches
242
- # what users see in their RSpec console output (Failure/Error: <source line>,
243
- # the exception class and message, and any "Caused by:" chain).
244
- def format_exception_message(exception, example)
245
- return '' unless exception
265
+ def pending_example_fixed?(exception)
266
+ defined?(RSpec::Core::Pending::PendingExampleFixedError) &&
267
+ exception.is_a?(RSpec::Core::Pending::PendingExampleFixedError)
268
+ end
269
+ end
246
270
 
247
- presenter = RSpec::Core::Formatters::ExceptionPresenter.new(exception, example)
248
- strip_ansi_codes(presenter.fully_formatted(nil, PlainColorizer))
249
- rescue StandardError
250
- legacy_format_exception_message(exception)
251
- end
271
+ # Formats failures for the report as plain text suitable for storage and the web UI.
272
+ module FailureFormatter
273
+ # A no-op colorizer passed to RSpec's ExceptionPresenter.
274
+ module PlainColorizer
275
+ module_function
252
276
 
253
- # trunk-ignore(rubocop/Metrics/MethodLength,rubocop/Metrics/AbcSize)
254
- def format_exception_backtrace(exception, example)
255
- return '' unless exception
277
+ def wrap(text, _code_or_symbol)
278
+ text
279
+ end
280
+ end
256
281
 
257
- lines = exception_backtrace_lines(exception, example)
282
+ module_function
258
283
 
259
- cause = exception.cause
260
- depth = 0
261
- while cause && depth < 10
262
- lines << ''
263
- lines << "Caused by: #{cause.class}: #{cause.message}"
264
- lines.concat(exception_backtrace_lines(cause, example))
265
- cause = cause.cause
266
- depth += 1
267
- end
284
+ # Defer to RSpec's own ExceptionPresenter so the failure_message field matches
285
+ # what users see in their RSpec console output (Failure/Error: <source line>,
286
+ # the exception class and message, and any "Caused by:" chain).
287
+ def message(exception, example)
288
+ return '' unless exception
268
289
 
269
- result = lines.join("\n")
270
- # The exception presenter may choke on MultipleExceptionError, such as errors in before
271
- # and after hooks, so we fall back to the legacy formatter
272
- return legacy_format_exception_backtrace(exception) if result.strip.empty?
290
+ presenter = RSpec::Core::Formatters::ExceptionPresenter.new(exception, example)
291
+ Trunk.strip_ansi_codes(presenter.fully_formatted(nil, PlainColorizer))
292
+ rescue StandardError
293
+ legacy_message(exception)
294
+ end
273
295
 
274
- strip_ansi_codes(result)
275
- rescue StandardError
276
- legacy_format_exception_backtrace(exception)
277
- end
296
+ # trunk-ignore(rubocop/Metrics/MethodLength,rubocop/Metrics/AbcSize)
297
+ def backtrace(exception, example)
298
+ return '' unless exception
278
299
 
279
- def exception_backtrace_lines(exception, example)
280
- presenter = RSpec::Core::Formatters::ExceptionPresenter.new(exception, example)
281
- Array(presenter.formatted_backtrace)
282
- rescue StandardError
283
- Array(exception.backtrace)
284
- end
300
+ lines = backtrace_lines(exception, example)
285
301
 
286
- def legacy_format_exception_message(exception)
287
- case exception
288
- when RSpec::Core::MultipleExceptionError
289
- messages = exception.all_exceptions.map { |e| "#{e.class}: #{e.message}" }
290
- strip_ansi_codes("#{exception.class}: #{messages.join(' | ')}")
291
- else
292
- strip_ansi_codes(exception.to_s)
293
- end
294
- end
302
+ cause = exception.cause
303
+ depth = 0
304
+ while cause && depth < 10
305
+ lines << ''
306
+ lines << "Caused by: #{cause.class}: #{cause.message}"
307
+ lines.concat(backtrace_lines(cause, example))
308
+ cause = cause.cause
309
+ depth += 1
310
+ end
295
311
 
296
- # trunk-ignore(rubocop/Metrics/MethodLength,rubocop/Metrics/AbcSize)
297
- def legacy_format_exception_backtrace(exception)
298
- case exception
299
- when RSpec::Core::MultipleExceptionError
300
- strip_ansi_codes(exception.all_exceptions.map do |e|
301
- if e.backtrace && !e.backtrace.empty?
302
- "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
303
- else
304
- "#{e.class}: #{e.message}"
305
- end
306
- end.join("\n\n"))
307
- else
308
- strip_ansi_codes(exception.backtrace&.join("\n") || '')
309
- end
310
- end
312
+ result = lines.join("\n")
313
+ # The exception presenter may choke on MultipleExceptionError, such as errors in before
314
+ # and after hooks, so we fall back to the legacy formatter
315
+ return legacy_backtrace(exception) if result.strip.empty?
311
316
 
312
- # TrunkAnalyticsListener is a class that is used to listen to the execution of the Example class
313
- # it generates and submits the final test reports
314
- class TrunkAnalyticsListener
315
- MAX_TEXT_FIELD_SIZE = 8_000
317
+ Trunk.strip_ansi_codes(result)
318
+ rescue StandardError
319
+ legacy_backtrace(exception)
320
+ end
316
321
 
317
- def initialize
318
- @testreport = $test_report
319
- end
322
+ def backtrace_lines(exception, example)
323
+ presenter = RSpec::Core::Formatters::ExceptionPresenter.new(exception, example)
324
+ Array(presenter.formatted_backtrace)
325
+ rescue StandardError
326
+ Array(exception.backtrace)
327
+ end
320
328
 
321
- def example_finished(notification)
322
- add_test_case(notification.example)
323
- end
329
+ def legacy_message(exception)
330
+ case exception
331
+ when RSpec::Core::MultipleExceptionError
332
+ messages = exception.all_exceptions.map { |e| "#{e.class}: #{e.message}" }
333
+ Trunk.strip_ansi_codes("#{exception.class}: #{messages.join(' | ')}")
334
+ else
335
+ Trunk.strip_ansi_codes(exception.to_s)
336
+ end
337
+ end
324
338
 
325
- # trunk-ignore(rubocop/Metrics/MethodLength,rubocop/Metrics/AbcSize)
326
- def close(_notification)
327
- if $failure_encountered_and_quarantining_disabled
328
- puts 'Note: Quarantining is disabled for this repo. Test failures were not quarantined.'.yellow
329
- end
330
- if $failure_encountered_and_quarantine_lookup_failed
331
- puts 'Note: Failed to check quarantining status. Test failures were not quarantined.'.yellow
339
+ # trunk-ignore(rubocop/Metrics/MethodLength,rubocop/Metrics/AbcSize)
340
+ def legacy_backtrace(exception)
341
+ case exception
342
+ when RSpec::Core::MultipleExceptionError
343
+ Trunk.strip_ansi_codes(exception.all_exceptions.map do |e|
344
+ if e.backtrace && !e.backtrace.empty?
345
+ "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
346
+ else
347
+ "#{e.class}: #{e.message}"
348
+ end
349
+ end.join("\n\n"))
350
+ else
351
+ Trunk.strip_ansi_codes(exception.backtrace&.join("\n") || '')
352
+ end
353
+ end
332
354
  end
333
355
 
334
- if ENV['TRUNK_LOCAL_UPLOAD_DIR']
335
- saved = @testreport.try_save(ENV['TRUNK_LOCAL_UPLOAD_DIR'])
336
- if saved
337
- puts 'Local Flaky tests report generated'.green
338
- else
339
- puts 'Failed to generate local flaky tests report'.red
356
+ # AnalyticsListener is an RSpec reporter listener that records each finished
357
+ # example in the run's test report and submits the report at the end.
358
+ class AnalyticsListener
359
+ MAX_TEXT_FIELD_SIZE = 8_000
360
+
361
+ def initialize(run)
362
+ @run = run
340
363
  end
341
- else
342
- published = @testreport.publish
343
- if published
344
- puts 'Flaky tests report upload complete'.green
345
- else
346
- puts 'Failed to publish flaky tests report'.red
364
+
365
+ def example_finished(notification)
366
+ add_test_case(notification.example)
347
367
  end
348
- end
349
- end
350
368
 
351
- # trunk-ignore(rubocop/Metrics/CyclomaticComplexity,rubocop/Metrics/AbcSize,rubocop/Metrics/MethodLength)
352
- def add_test_case(example)
353
- status, exception = status_and_exception(example)
354
- failure_message = ''
355
- backtrace = ''
356
- if exception
357
- failure_message = format_exception_message(exception, example).strip
358
- backtrace = format_exception_backtrace(exception, example).strip
359
- end
360
- failure_message = failure_message[0...MAX_TEXT_FIELD_SIZE] if failure_message.length > MAX_TEXT_FIELD_SIZE
361
- backtrace = backtrace[0...MAX_TEXT_FIELD_SIZE] if backtrace.length > MAX_TEXT_FIELD_SIZE
362
- # TODO: should we use concatenated string or alias when auto-generated description?
363
- name = example.full_description
364
- file = escape(example.metadata[:file_path])
365
- classname = file.sub(%r{\.[^/.]+\Z}, '').gsub('/', '.').gsub(/\A\.+|\.+\Z/, '')
366
- line = example.metadata[:line_number]
367
- started_at = example.execution_result.started_at.to_i
368
- finished_at = example.execution_result.finished_at.to_i
369
- id = example.generate_trunk_id
370
-
371
- attempt_number = example.metadata[:retry_attempts] || example.metadata[:attempt_number] || 0
372
- # set the status to failure, but mark it as quarantined
373
- is_quarantined = example.metadata[:quarantined_exception] ? true : false
374
- parent_name = example.example_group.metadata[:description]
375
- parent_name = parent_name.empty? ? 'rspec' : parent_name
376
- @testreport.add_test(id, name, classname, file, parent_name, line, status, attempt_number,
377
- started_at, finished_at, failure_message || '', backtrace || '', is_quarantined)
378
- end
369
+ # trunk-ignore(rubocop/Metrics/MethodLength,rubocop/Metrics/AbcSize)
370
+ def close(_notification)
371
+ if @run.quarantining_disabled?
372
+ puts Colors.yellow('Note: Quarantining is disabled for this repo. Test failures were not quarantined.')
373
+ end
374
+ if @run.lookup_failed?
375
+ puts Colors.yellow('Note: Failed to check quarantining status. Test failures were not quarantined.')
376
+ end
379
377
 
380
- # Determine the Trunk status to report for an example, plus the exception (if
381
- # any) whose message/backtrace should be recorded for it.
382
- #
383
- # `pending` examples need care because RSpec expresses their outcome as the
384
- # inverse of the body's pass/fail. A pending example's contract is "this is
385
- # expected to fail", so:
386
- # - body still fails -> expectation met -> RSpec status :pending, build green
387
- # - body now passes -> expectation violated -> RSpec status :failed
388
- # (PendingExampleFixedError), build red
389
- # We report the outcome RSpec actually decided, which also matches the build
390
- # result:
391
- # - skip / xit (never ran) -> skipped
392
- # - pending, body still failing -> success (expectation met)
393
- # - pending, body now passing -> failure (PendingExampleFixedError)
394
- # RSpec's own build pass/fail behavior is left untouched (see #set_exception).
395
- #
396
- # trunk-ignore(rubocop/Metrics/AbcSize,rubocop/Metrics/CyclomaticComplexity,rubocop/Metrics/MethodLength)
397
- def status_and_exception(example)
398
- result = example.execution_result
399
- quarantined_exception = example.metadata[:quarantined_exception]
400
-
401
- # A genuinely skipped example (`skip`/`xit`, or `skip` called from a hook or
402
- # body) never runs, so it has no real pass/fail outcome. RSpec still reports
403
- # it with status :pending, so detect skips explicitly -- and up front -- via
404
- # metadata[:skip] before interpreting any pending pass/fail semantics below.
405
- return [Status.new('skipped'), nil] if example.metadata[:skip]
406
-
407
- case result.status
408
- when :passed
409
- # A quarantined failure is recorded as :passed by RSpec (see #set_exception),
410
- # so report it as a failure but carry the original quarantined exception.
411
- quarantined_exception ? [Status.new('failure'), quarantined_exception] : [Status.new('success'), nil]
412
- when :failed
413
- # Includes a pending example whose body unexpectedly passed: RSpec reports it
414
- # as :failed with a PendingExampleFixedError (on example.exception) and breaks
415
- # the build (the pending expectation was violated), so it is a failure here too.
416
- [Status.new('failure'), example.exception || quarantined_exception]
417
- when :pending
418
- # A quarantined fixed-pending is left :pending but pending_fixed? -- still a
419
- # failure. Otherwise the pending "should fail" expectation was met -> success.
420
- if result.pending_fixed?
421
- [Status.new('failure'), quarantined_exception || example.exception]
422
- else
423
- [Status.new('success'), nil]
424
- end
425
- else
426
- [Status.new(result.status.to_s), example.exception || quarantined_exception]
427
- end
428
- end
429
- end
378
+ if ENV['TRUNK_LOCAL_UPLOAD_DIR']
379
+ saved = @run.report.try_save(ENV['TRUNK_LOCAL_UPLOAD_DIR'])
380
+ if saved
381
+ puts Colors.green('Local Flaky tests report generated')
382
+ else
383
+ puts Colors.red('Failed to generate local flaky tests report')
384
+ end
385
+ else
386
+ published = @run.report.publish
387
+ if published
388
+ puts Colors.green('Flaky tests report upload complete')
389
+ else
390
+ puts Colors.red('Failed to publish flaky tests report')
391
+ end
392
+ end
393
+ end
430
394
 
431
- RSpec.configure do |c|
432
- next if trunk_disabled
395
+ # trunk-ignore(rubocop/Metrics/CyclomaticComplexity,rubocop/Metrics/AbcSize,rubocop/Metrics/MethodLength)
396
+ def add_test_case(example)
397
+ status, exception = status_and_exception(example)
398
+ failure_message = ''
399
+ backtrace = ''
400
+ if exception
401
+ failure_message = FailureFormatter.message(exception, example).strip
402
+ backtrace = FailureFormatter.backtrace(exception, example).strip
403
+ end
404
+ failure_message = failure_message[0...MAX_TEXT_FIELD_SIZE] if failure_message.length > MAX_TEXT_FIELD_SIZE
405
+ backtrace = backtrace[0...MAX_TEXT_FIELD_SIZE] if backtrace.length > MAX_TEXT_FIELD_SIZE
406
+ # TODO: should we use concatenated string or alias when auto-generated description?
407
+ name = example.full_description
408
+ file, classname = Trunk.file_and_classname(example)
409
+ line = example.metadata[:line_number]
410
+ started_at = example.execution_result.started_at.to_i
411
+ finished_at = example.execution_result.finished_at.to_i
412
+
413
+ attempt_number = example.metadata[:retry_attempts] || example.metadata[:trunk_attempt_number] || 0
414
+ # set the status to failure, but mark it as quarantined
415
+ is_quarantined = example.metadata[:trunk_quarantined_exception] ? true : false
416
+ @run.report.add_test(example.trunk_id, name, classname, file, Trunk.parent_name(example), line, status,
417
+ attempt_number, started_at, finished_at, failure_message || '', backtrace || '',
418
+ is_quarantined)
419
+ end
433
420
 
434
- c.before(:example) do
435
- if $failure_encountered_and_quarantine_lookup_failed && quarantine_query_failure_exit?
436
- skip('Quarantine lookup failed, skipping test run')
421
+ # Determine the Trunk status to report for an example, plus the exception (if
422
+ # any) whose message/backtrace should be recorded for it.
423
+ #
424
+ # `pending` examples need care because RSpec expresses their outcome as the
425
+ # inverse of the body's pass/fail. A pending example's contract is "this is
426
+ # expected to fail", so:
427
+ # - body still fails -> expectation met -> RSpec status :pending, build green
428
+ # - body now passes -> expectation violated -> RSpec status :failed
429
+ # (PendingExampleFixedError), build red
430
+ # We report the outcome RSpec actually decided, which also matches the build
431
+ # result:
432
+ # - skip / xit (never ran) -> skipped
433
+ # - pending, body still failing -> success (expectation met)
434
+ # - pending, body now passing -> failure (PendingExampleFixedError)
435
+ # RSpec's own build pass/fail behavior is left untouched (see #set_exception).
436
+ #
437
+ # trunk-ignore(rubocop/Metrics/AbcSize,rubocop/Metrics/CyclomaticComplexity,rubocop/Metrics/MethodLength)
438
+ def status_and_exception(example)
439
+ result = example.execution_result
440
+ quarantined_exception = example.metadata[:trunk_quarantined_exception]
441
+
442
+ # A genuinely skipped example (`skip`/`xit`, or `skip` called from a hook or
443
+ # body) never runs, so it has no real pass/fail outcome. RSpec still reports
444
+ # it with status :pending, so detect skips explicitly -- and up front -- via
445
+ # metadata[:skip] before interpreting any pending pass/fail semantics below.
446
+ return [Status.new('skipped'), nil] if example.metadata[:skip]
447
+
448
+ case result.status
449
+ when :passed
450
+ # A quarantined failure is recorded as :passed by RSpec (see #set_exception),
451
+ # so report it as a failure but carry the original quarantined exception.
452
+ quarantined_exception ? [Status.new('failure'), quarantined_exception] : [Status.new('success'), nil]
453
+ when :failed
454
+ # Includes a pending example whose body unexpectedly passed: RSpec reports it
455
+ # as :failed with a PendingExampleFixedError (on example.exception) and breaks
456
+ # the build (the pending expectation was violated), so it is a failure here too.
457
+ [Status.new('failure'), example.exception || quarantined_exception]
458
+ when :pending
459
+ # A quarantined fixed-pending is left :pending but pending_fixed? -- still a
460
+ # failure. Otherwise the pending "should fail" expectation was met -> success.
461
+ if result.pending_fixed?
462
+ [Status.new('failure'), quarantined_exception || example.exception]
463
+ else
464
+ [Status.new('success'), nil]
465
+ end
466
+ else
467
+ [Status.new(result.status.to_s), example.exception || quarantined_exception]
468
+ end
469
+ end
437
470
  end
438
471
  end
439
-
440
- c.reporter.register_listener TrunkAnalyticsListener.new, :example_finished, :close
441
472
  end
442
473
 
443
474
  RSpec::Trunk.setup
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec_trunk_flaky_tests
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.6
4
+ version: 0.16.0.pre.beta.1
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Trunk Technologies, Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-25 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec-core