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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +111 -0
- data/Rakefile +80 -0
- data/lib/rspec/capturing_formatter/configuration.rb +77 -0
- data/lib/rspec/capturing_formatter/renderer.rb +457 -0
- data/lib/rspec/capturing_formatter/sanitizer.rb +378 -0
- data/lib/rspec/capturing_formatter/stream_proxy.rb +414 -0
- data/lib/rspec/capturing_formatter/version.rb +9 -0
- data/lib/rspec/capturing_formatter/windows_command_line.rb +65 -0
- data/lib/rspec/capturing_formatter/windows_terminal.rb +87 -0
- data/lib/rspec/capturing_formatter.rb +319 -0
- metadata +120 -0
|
@@ -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
|