rspec-capturing-formatter 1.0.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,457 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module RSpec
6
+ class CapturingFormatter
7
+ # Builds the append-only report and owns captured-line boundaries and destination writes.
8
+ class Renderer
9
+ RESET = "\e[0m"
10
+ COLORS = {
11
+ green: "\e[32m",
12
+ red: "\e[31m",
13
+ yellow: "\e[33m",
14
+ cyan: "\e[36m",
15
+ gray: "\e[90m",
16
+ pink: "\e[95m",
17
+ bold: "\e[1m"
18
+ }.freeze
19
+ SOURCE_COLORS = {
20
+ "stdout" => :gray,
21
+ "stderr" => :yellow,
22
+ "suite stdout" => :gray,
23
+ "suite stderr" => :yellow
24
+ }.freeze
25
+ BOM_ENCODINGS = {
26
+ "UTF-16" => "UTF-16BE",
27
+ "UTF-32" => "UTF-32BE"
28
+ }.freeze
29
+
30
+ attr_reader :output
31
+
32
+ def initialize(output, configuration, capture_manager: nil, ansi_supported: nil)
33
+ @output = output || StringIO.new
34
+ @configuration = configuration
35
+ @capture_manager = capture_manager
36
+ @ansi_supported = ansi_supported.nil? ? WindowsTerminal.ansi_supported?(@output) : ansi_supported
37
+ @entry_started = false
38
+ @entry_kind = nil
39
+ @entry_path = nil
40
+ @capture_source = nil
41
+ @capture_open = false
42
+ @capture_styled = false
43
+ @sanitizers = {}
44
+ @pending_output = nil
45
+ @bom_output_encoding = nil
46
+ @bom_written = false
47
+ end
48
+
49
+ def failure_colorizer
50
+ FailureColorizer.new(self)
51
+ end
52
+
53
+ def example_started(path)
54
+ finish_capture
55
+ begin_entry
56
+ @entry_kind = :example
57
+ @entry_path = path
58
+ line(path, :bold)
59
+ end
60
+
61
+ def context_started(path, heading: true)
62
+ return if !heading && @entry_kind == :context && @entry_path == path
63
+
64
+ finish_capture
65
+ begin_entry
66
+ @entry_kind = :context
67
+ @entry_path = path
68
+ line(path) if heading
69
+ end
70
+
71
+ def suite_started(heading: true)
72
+ return if !heading && @entry_kind == :suite
73
+
74
+ finish_capture
75
+ begin_entry
76
+ @entry_kind = :suite
77
+ @entry_path = "RSpec suite"
78
+ line("RSpec suite") if heading
79
+ end
80
+
81
+ def capture(source, value, encoding = nil)
82
+ if @capture_source != source
83
+ finish_capture
84
+ @capture_source = source
85
+ end
86
+
87
+ text = (@sanitizers[source] ||= Sanitizer.new).process(value, encoding)
88
+ emit_captured_text(source, text)
89
+ end
90
+
91
+ def message(lines, inside_example: false)
92
+ finish_capture
93
+ unless inside_example && @entry_kind == :example
94
+ begin_entry
95
+ @entry_kind = :rspec
96
+ @entry_path = nil
97
+ end
98
+ lines = lines.to_s.lines
99
+ lines = [""] if lines.empty?
100
+ lines.each { |entry| line(" rspec | #{entry.chomp}") }
101
+ end
102
+
103
+ def result(status, run_time = nil)
104
+ finish_capture
105
+ label, color = status_label(status)
106
+ suffix = duration_suffix(run_time)
107
+ line(" #{style(label, color)}#{suffix}")
108
+ end
109
+
110
+ def pending(reason, location, skipped: false, run_time: nil)
111
+ finish_capture
112
+ label, color = status_label(skipped ? :skipped : :pending)
113
+ line(" #{style(label, color)}#{duration_suffix(run_time)}")
114
+ line(" reason | #{reason}") unless reason.to_s.empty?
115
+ line(" rerun | #{location}") unless location.to_s.empty?
116
+ end
117
+
118
+ def rerun_inline(command)
119
+ finish_capture
120
+ line(" rerun | #{command}")
121
+ end
122
+
123
+ def failure(notification_lines)
124
+ finish_capture
125
+ notification_lines.each do |entry|
126
+ value = entry.to_s.chomp
127
+ line(value.empty? ? "" : " #{value}")
128
+ end
129
+ end
130
+
131
+ def summary(total:, succeeded:, failed:, pending:, duration:, load_time:, errors: 0)
132
+ finish_capture
133
+ begin_entry
134
+ @entry_kind = :summary
135
+ @entry_path = nil
136
+ summary_color = failed.to_i.zero? ? :green : :red
137
+ line(style("Summary", :bold))
138
+ line(style(" #{total} total #{succeeded} succeeded #{failed} failed #{pending} pending", summary_color))
139
+ line(style(" Finished in #{format_seconds(duration)} | files loaded in #{format_milliseconds(load_time)}", summary_color))
140
+ line(style(" #{errors} errors outside examples", summary_color)) if errors.to_i.positive?
141
+ end
142
+
143
+ def profile(notification)
144
+ examples = notification.respond_to?(:slowest_examples) ? notification.slowest_examples : []
145
+ examples = Array(examples)
146
+
147
+ finish_capture
148
+ begin_entry
149
+ @entry_kind = :profile
150
+ @entry_path = nil
151
+ line("Profile")
152
+ if notification.respond_to?(:slow_duration) && notification.respond_to?(:percentage)
153
+ line(" Slowest examples total #{format_seconds(notification.slow_duration)} (#{notification.percentage}%)")
154
+ end
155
+ examples.each do |example|
156
+ description = example.respond_to?(:full_description) ? example.full_description : example.to_s
157
+ runtime = example.respond_to?(:execution_result) ? example.execution_result.run_time : nil
158
+ location = example.location if example.respond_to?(:location)
159
+ suffix = location.to_s.empty? ? "" : " #{location}"
160
+ line(" #{format_seconds(runtime)} #{description}#{suffix}")
161
+ end
162
+ groups = notification.slowest_groups if notification.respond_to?(:slowest_groups)
163
+ unless groups.nil? || groups.empty?
164
+ line(" Slowest example groups")
165
+ groups.each do |location, data|
166
+ total = data[:total_time] if data.respond_to?(:[])
167
+ count = data[:count] if data.respond_to?(:[])
168
+ average = data[:average] if data.respond_to?(:[])
169
+ description = data[:description] if data.respond_to?(:[])
170
+ line(" #{description}") unless description.to_s.empty?
171
+ line(
172
+ " #{format_seconds(total)} total #{format_seconds(average)} average " \
173
+ "#{count} examples #{location}"
174
+ )
175
+ end
176
+ end
177
+ line(" No examples profiled") if examples.empty? && (groups.nil? || groups.empty?)
178
+ end
179
+
180
+ def reruns(commands)
181
+ return if commands.empty?
182
+
183
+ finish_capture
184
+ begin_entry
185
+ @entry_kind = :reruns
186
+ @entry_path = nil
187
+ line("Failed examples")
188
+ commands.each { |command| line(" #{command}") }
189
+ end
190
+
191
+ def seed(seed)
192
+ return if seed.nil?
193
+
194
+ finish_capture
195
+ begin_entry
196
+ @entry_kind = :seed
197
+ @entry_path = nil
198
+ line(" Randomized with seed #{seed}")
199
+ end
200
+
201
+ def finish_capture
202
+ return unless @capture_open || @capture_source || @capture_styled
203
+
204
+ sanitizer = @sanitizers[@capture_source]
205
+ trailing = sanitizer&.finish.to_s
206
+ emit_captured_text(@capture_source, trailing) unless trailing.empty?
207
+ if @capture_open
208
+ write_raw(RESET) if color_enabled? || @capture_styled
209
+ write_raw("\n")
210
+ elsif @capture_styled
211
+ # A capture boundary must reset application SGR even when its last line already ended.
212
+ write_raw(RESET)
213
+ end
214
+ @capture_open = false
215
+ @capture_source = nil
216
+ @capture_styled = false
217
+ end
218
+
219
+ def flush_pending
220
+ flush_pending_output
221
+ end
222
+
223
+ private
224
+
225
+ def emit_captured_text(source, text)
226
+ text = strip_sgr(text) unless @ansi_supported
227
+ return if text.empty?
228
+
229
+ # Once application SGR appears, formatter source color stays off until the capture boundary.
230
+ @capture_styled = true if text.match?(/\e\[[0-?]*+[ -\/]*+m/)
231
+
232
+ begin_entry unless @entry_started
233
+ prefix = style(" #{source} | ", SOURCE_COLORS[source])
234
+ rendered = +""
235
+ unless @capture_open
236
+ rendered << RESET if color_enabled?
237
+ rendered << prefix
238
+ end
239
+
240
+ parts = text.split("\n", -1)
241
+ parts.each_with_index do |part, index|
242
+ rendered << captured_part(source, part)
243
+ next unless index < parts.length - 1
244
+
245
+ rendered << RESET if color_enabled?
246
+ rendered << "\n"
247
+ # A trailing newline closes the line without starting an empty prefixed line.
248
+ rendered << prefix unless index == parts.length - 2 && parts.last.empty?
249
+ end
250
+
251
+ @capture_open = true unless text.end_with?("\n")
252
+ @capture_open = false if text.end_with?("\n")
253
+ write_raw(rendered)
254
+ end
255
+
256
+ def captured_part(source, value)
257
+ return value if value.empty? || @capture_styled
258
+
259
+ style(value, SOURCE_COLORS[source])
260
+ end
261
+
262
+ def begin_entry
263
+ finish_capture
264
+ write_raw("\n") if @entry_started
265
+ @entry_started = true
266
+ end
267
+
268
+ def line(value, color = nil)
269
+ value = value.to_s
270
+ value = strip_sgr(value) unless @ansi_supported
271
+ write_raw(RESET) if color_enabled?
272
+ write_raw(style(value, color))
273
+ write_raw("\n")
274
+ end
275
+
276
+ def write_raw(value)
277
+ return if value.nil? || value.empty?
278
+
279
+ text = encode_for_output(value)
280
+ if @pending_output.nil? || @pending_output.empty?
281
+ @pending_output = text.dup
282
+ else
283
+ @pending_output << text
284
+ end
285
+ flush_pending_output
286
+ end
287
+
288
+ def flush_pending_output
289
+ while @pending_output && !@pending_output.empty?
290
+ written = if @capture_manager
291
+ @capture_manager.bypass { write_pending_chunk }
292
+ elsif defined?(CaptureManager)
293
+ # An omitted manager still uses the process-global bypass to avoid recursive capture.
294
+ CaptureManager.instance.bypass { write_pending_chunk }
295
+ else
296
+ write_pending_chunk
297
+ end
298
+ written = @pending_output.bytesize if written.nil?
299
+ remaining = @pending_output.byteslice(written..)
300
+ @pending_output = (remaining && !remaining.empty?) ? remaining : nil
301
+ end
302
+ @output.flush if @output.respond_to?(:flush)
303
+ end
304
+
305
+ def write_pending_chunk
306
+ if @output.respond_to?(:write_nonblock)
307
+ written = @output.write_nonblock(@pending_output)
308
+ raise Errno::EAGAIN if written == :wait_writable
309
+
310
+ written
311
+ else
312
+ @output.write(@pending_output)
313
+ end
314
+ rescue EncodingError, ArgumentError
315
+ raise if @pending_output.ascii_only?
316
+
317
+ @pending_output = ascii_fallback(@pending_output)
318
+ retry
319
+ end
320
+
321
+ def encode_for_output(value)
322
+ requested = @output.external_encoding if @output.respond_to?(:external_encoding)
323
+ return value unless requested
324
+
325
+ # Fixed byte order avoids the repeated BOM that generic UTF-16 or UTF-32 emits per fragment.
326
+ encoding = BOM_ENCODINGS.fetch(requested.name, requested.name)
327
+ activate_bom_destination(encoding) if BOM_ENCODINGS.key?(requested.name)
328
+ encoded = begin
329
+ value.encode(encoding)
330
+ rescue EncodingError, TypeError
331
+ fallback = value.each_char.map do |character|
332
+ character.encode(encoding).encode(Encoding::UTF_8)
333
+ rescue EncodingError, TypeError
334
+ (character.bytesize == 1) ? format("\\x%02X", character.getbyte(0)) : format("\\u{%X}", character.ord)
335
+ end.join
336
+ fallback.encode(encoding)
337
+ end
338
+
339
+ if @bom_output_encoding && !@bom_written
340
+ @bom_written = true
341
+ "\uFEFF".encode(@bom_output_encoding) + encoded
342
+ else
343
+ encoded
344
+ end
345
+ end
346
+
347
+ def activate_bom_destination(encoding)
348
+ return if @bom_output_encoding
349
+
350
+ @bom_output_encoding = encoding
351
+ @output.set_encoding(encoding) if @output.respond_to?(:set_encoding)
352
+ rescue EncodingError, TypeError
353
+ # Pre-encoded bytes remain writable when a destination exposes but rejects set_encoding.
354
+ @bom_output_encoding = encoding
355
+ end
356
+
357
+ def ascii_fallback(value)
358
+ value.bytes.map do |byte|
359
+ (byte == 9 || byte == 10 || byte == 13 || byte.between?(0x20, 0x7E)) ? byte.chr : format("\\x%02X", byte)
360
+ end.join
361
+ end
362
+
363
+ def color_enabled?
364
+ return false unless @ansi_supported
365
+ return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
366
+ return false if defined?(RSpec) && RSpec.respond_to?(:configuration) &&
367
+ RSpec.configuration.respond_to?(:color_mode) && RSpec.configuration.color_mode == :off
368
+
369
+ @configuration.color
370
+ end
371
+
372
+ def style(value, color)
373
+ return value unless color_enabled?
374
+ return value unless color
375
+
376
+ "#{COLORS.fetch(color)}#{value}#{RESET}"
377
+ end
378
+
379
+ def strip_sgr(value)
380
+ value.gsub(/\e\[[0-?]*+[ -\/]*+m/, "")
381
+ end
382
+
383
+ def status_label(status)
384
+ case status
385
+ when :passed then [glyph("✅", "[PASS]") + " succeeded", :green]
386
+ when :failed then [glyph("❌", "[FAIL]") + " failed", :red]
387
+ when :pending then [glyph("⏸", "[PENDING]") + " pending", :yellow]
388
+ when :skipped then [glyph("↪", "[SKIP]") + " skipped", :yellow]
389
+ else [status.to_s, nil]
390
+ end
391
+ end
392
+
393
+ def glyph(unicode, ascii)
394
+ return ascii if @configuration.emoji == false
395
+ return unicode if emoji_supported?(unicode)
396
+
397
+ ascii
398
+ end
399
+
400
+ def emoji_supported?(value)
401
+ return false unless @output.respond_to?(:external_encoding)
402
+ encoding = @output.external_encoding
403
+ return true unless encoding
404
+
405
+ value.encode(encoding)
406
+ true
407
+ rescue EncodingError, TypeError
408
+ false
409
+ end
410
+
411
+ def duration_suffix(run_time)
412
+ threshold = @configuration.slow_threshold
413
+ return "" if threshold.nil? || run_time.nil? || run_time < threshold
414
+
415
+ style(" #{format_seconds(run_time)}", :pink)
416
+ end
417
+
418
+ def format_seconds(value)
419
+ seconds = value.to_f
420
+ (seconds < 1) ? format("%.0f ms", seconds * 1000) : format("%.2f s", seconds)
421
+ end
422
+
423
+ def format_milliseconds(value)
424
+ format("%.1f ms", value.to_f * 1000)
425
+ end
426
+
427
+ # Adapts the formatter's color policy to RSpec's failure-presenter interface.
428
+ class FailureColorizer
429
+ def initialize(renderer)
430
+ @renderer = renderer
431
+ end
432
+
433
+ def wrap(text, color)
434
+ return text unless @renderer.formatter_color_enabled?
435
+
436
+ code = COLORS[color]
437
+ code ? "#{code}#{text}#{RESET}" : text
438
+ end
439
+
440
+ private
441
+
442
+ COLORS = {
443
+ red: "\e[31m",
444
+ green: "\e[32m",
445
+ yellow: "\e[33m",
446
+ cyan: "\e[36m",
447
+ bold: "\e[1m"
448
+ }.freeze
449
+ end
450
+
451
+ def formatter_color_enabled?
452
+ color_enabled?
453
+ end
454
+ public :formatter_color_enabled?
455
+ end
456
+ end
457
+ end