constable-rails 0.1.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/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +515 -0
- data/exe/constable +7 -0
- data/lib/constable/case.rb +336 -0
- data/lib/constable/cli.rb +475 -0
- data/lib/constable/cold_case/minitest.rb +342 -0
- data/lib/constable/cold_case/rspec.rb +334 -0
- data/lib/constable/cold_case.rb +280 -0
- data/lib/constable/config.rb +125 -0
- data/lib/constable/coverage.rb +951 -0
- data/lib/constable/diff.rb +212 -0
- data/lib/constable/dsl.rb +833 -0
- data/lib/constable/identity.rb +121 -0
- data/lib/constable/importer/modernizer.rb +860 -0
- data/lib/constable/importer/reopener.rb +468 -0
- data/lib/constable/importer.rb +51 -0
- data/lib/constable/investigation.rb +67 -0
- data/lib/constable/isolation.rb +171 -0
- data/lib/constable/jail.rb +399 -0
- data/lib/constable/log_router.rb +197 -0
- data/lib/constable/matchers.rb +834 -0
- data/lib/constable/order_audit.rb +130 -0
- data/lib/constable/rails_support.rb +213 -0
- data/lib/constable/railtie.rb +36 -0
- data/lib/constable/registry.rb +57 -0
- data/lib/constable/reporter.rb +625 -0
- data/lib/constable/result.rb +149 -0
- data/lib/constable/runner.rb +697 -0
- data/lib/constable/selection.rb +205 -0
- data/lib/constable/storage/adapter.rb +91 -0
- data/lib/constable/storage/mysql_adapter.rb +125 -0
- data/lib/constable/storage/postgres_adapter.rb +125 -0
- data/lib/constable/storage/sqlite_adapter.rb +84 -0
- data/lib/constable/storage.rb +847 -0
- data/lib/constable/version.rb +5 -0
- data/lib/constable/warrants.rb +290 -0
- data/lib/constable-rails.rb +16 -0
- data/lib/constable.rb +151 -0
- data/lib/generators/constable/base.rb +99 -0
- data/lib/generators/constable/channel/channel_generator.rb +20 -0
- data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
- data/lib/generators/constable/controller/controller_generator.rb +25 -0
- data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
- data/lib/generators/constable/generator/generator_generator.rb +31 -0
- data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
- data/lib/generators/constable/helper/helper_generator.rb +23 -0
- data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
- data/lib/generators/constable/import_generator.rb +137 -0
- data/lib/generators/constable/install_generator.rb +188 -0
- data/lib/generators/constable/integration/integration_generator.rb +27 -0
- data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
- data/lib/generators/constable/job/job_generator.rb +20 -0
- data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
- data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
- data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
- data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
- data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
- data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
- data/lib/generators/constable/model/model_generator.rb +31 -0
- data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
- data/lib/generators/constable/resource/resource_generator.rb +27 -0
- data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
- data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
- data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
- data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
- data/lib/generators/constable/system/system_generator.rb +20 -0
- data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
- data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
- data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
- data/lib/generators/constable/templates/config.yml.tt +67 -0
- data/lib/generators/constable/templates/example_case.rb.tt +56 -0
- data/lib/generators/constable/templates/matchers.rb.tt +36 -0
- data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
- metadata +209 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "constable/result"
|
|
4
|
+
require "constable/log_router"
|
|
5
|
+
|
|
6
|
+
module Constable
|
|
7
|
+
# The face of the product. Two jobs, and nothing else on stdout:
|
|
8
|
+
#
|
|
9
|
+
# 1. While the suite runs, one compact glyph per finished test, grouped by case.
|
|
10
|
+
# 2. When it finishes, the summary -- which is the actual deliverable. Everything a
|
|
11
|
+
# developer needs to act on is in it: what broke, where, why, and the exact command
|
|
12
|
+
# to rerun it. Sections print worst-to-least-urgent, and nothing that bends the
|
|
13
|
+
# rules (a jailed test, a warrant, an unsafe block) is ever silently dropped.
|
|
14
|
+
#
|
|
15
|
+
# Logs do not belong here -- Rails.logger, SQL and request logging go to log/test.log
|
|
16
|
+
# via Constable::LogRouter. stdout is results only.
|
|
17
|
+
#
|
|
18
|
+
# The Runner drives it:
|
|
19
|
+
#
|
|
20
|
+
# reporter = Constable::Reporter.new(io: $stdout, config: Constable.config)
|
|
21
|
+
# reporter.start(total: 482, seed: 8841)
|
|
22
|
+
# reporter.record(result) # one glyph, live
|
|
23
|
+
# reporter.finish(results: all, duration: 12.4, seed: 8841, coverage: cov)
|
|
24
|
+
# exit reporter.exit_status
|
|
25
|
+
class Reporter
|
|
26
|
+
# The summary's frame. 60 columns wide, as published in the spec.
|
|
27
|
+
RULE_WIDTH = 60
|
|
28
|
+
HEAVY_RULE = ("━" * RULE_WIDTH).freeze
|
|
29
|
+
|
|
30
|
+
INDENT = " "
|
|
31
|
+
ENTRY_INDENT = " "
|
|
32
|
+
DETAIL_INDENT = " "
|
|
33
|
+
|
|
34
|
+
# " CONSTABLE" padded so the run stats always start in the same column.
|
|
35
|
+
LABEL = "CONSTABLE"
|
|
36
|
+
LABEL_WIDTH = 21
|
|
37
|
+
|
|
38
|
+
# The live stream pads case names into a column so the glyph runs line up.
|
|
39
|
+
STREAM_NAME_WIDTH = 34
|
|
40
|
+
STREAM_GAP = 2
|
|
41
|
+
|
|
42
|
+
# How many glyphs may pile up behind the currently-streaming case before we give
|
|
43
|
+
# up waiting for it and start that case's own line. Parallel workers interleave;
|
|
44
|
+
# without a limit a quiet case could hold the buffer forever.
|
|
45
|
+
STREAM_FLUSH_THRESHOLD = 12
|
|
46
|
+
|
|
47
|
+
DEFAULT_SLOWEST = 5
|
|
48
|
+
|
|
49
|
+
# Result::GLYPHS covers statuses. These four are summary vocabulary, not statuses:
|
|
50
|
+
# supervision and coverage are properties of a test, not outcomes of one.
|
|
51
|
+
GLYPHS = Result::GLYPHS.merge(
|
|
52
|
+
parole: "◑",
|
|
53
|
+
warrant: "⚖",
|
|
54
|
+
warning: "⚠",
|
|
55
|
+
coverage: "◐"
|
|
56
|
+
).freeze
|
|
57
|
+
|
|
58
|
+
STYLES = {
|
|
59
|
+
reset: 0,
|
|
60
|
+
bold: 1,
|
|
61
|
+
dim: 2,
|
|
62
|
+
red: 31,
|
|
63
|
+
green: 32,
|
|
64
|
+
yellow: 33,
|
|
65
|
+
blue: 34,
|
|
66
|
+
magenta: 35,
|
|
67
|
+
cyan: 36
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
COLORS = {
|
|
71
|
+
passed: :green,
|
|
72
|
+
failed: :red,
|
|
73
|
+
errored: :red,
|
|
74
|
+
parole_violation: :red,
|
|
75
|
+
jailed: :yellow,
|
|
76
|
+
warranted: :yellow,
|
|
77
|
+
warning: :yellow,
|
|
78
|
+
skipped: :dim,
|
|
79
|
+
parole: :cyan,
|
|
80
|
+
coverage: :cyan
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
# The spec prints SLOWEST's rule one character longer than its title. Reproduced
|
|
84
|
+
# verbatim so our output matches the published example character for character;
|
|
85
|
+
# drop the entry to make every underline title-width.
|
|
86
|
+
UNDERLINE_WIDTHS = { "SLOWEST" => 8 }.freeze
|
|
87
|
+
|
|
88
|
+
# Wide (East Asian / emoji) code points occupy two columns; combining marks occupy
|
|
89
|
+
# none. Padding by byte length or even by character count would misalign the glyph
|
|
90
|
+
# column the moment a name or a glyph stops being plain ASCII.
|
|
91
|
+
WIDE_RANGES = [
|
|
92
|
+
0x1100..0x115F, 0x2E80..0x303E, 0x3041..0x33FF, 0x3400..0x4DBF, 0x4E00..0x9FFF,
|
|
93
|
+
0xA000..0xA4CF, 0xAC00..0xD7A3, 0xF900..0xFAFF, 0xFE10..0xFE19, 0xFE30..0xFE6F,
|
|
94
|
+
0xFF00..0xFF60, 0xFFE0..0xFFE6, 0x1F300..0x1F64F, 0x1F680..0x1F6FF,
|
|
95
|
+
0x1F900..0x1F9FF, 0x20000..0x3FFFD
|
|
96
|
+
].freeze
|
|
97
|
+
|
|
98
|
+
attr_reader :io, :config, :seed, :total
|
|
99
|
+
|
|
100
|
+
def initialize(io: $stdout, config: nil, color: nil, slowest: DEFAULT_SLOWEST)
|
|
101
|
+
@io = io
|
|
102
|
+
@config = config || Constable.config
|
|
103
|
+
@color = resolve_color(color)
|
|
104
|
+
@slowest = slowest.to_i
|
|
105
|
+
@io.set_encoding(Encoding::UTF_8) if @io.respond_to?(:set_encoding)
|
|
106
|
+
|
|
107
|
+
reset_stream!
|
|
108
|
+
@failed_live = 0
|
|
109
|
+
@warning_count = 0
|
|
110
|
+
@finished = false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# --- lifecycle -----------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
# Announces the run. The seed is always printed: every summary that mentions a
|
|
116
|
+
# failure hands back a rerun command, and the command is only replayable with it.
|
|
117
|
+
def start(total: nil, seed: nil)
|
|
118
|
+
@total = total
|
|
119
|
+
@seed = seed
|
|
120
|
+
return self if total.nil? && seed.nil?
|
|
121
|
+
|
|
122
|
+
bits = []
|
|
123
|
+
bits << "#{total} #{pluralize(total, "test")}" if total
|
|
124
|
+
bits << "seed #{seed}" if seed
|
|
125
|
+
writeln(paint("#{LABEL.downcase} · #{bits.join(" · ")}", :dim))
|
|
126
|
+
writeln
|
|
127
|
+
self
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# One glyph, live, as each test completes. Safe to call from the parent process
|
|
131
|
+
# only -- workers ship results home and the parent reports them.
|
|
132
|
+
def record(result)
|
|
133
|
+
return self if result.nil?
|
|
134
|
+
|
|
135
|
+
@failed_live += 1 if result.failed?
|
|
136
|
+
stream(result)
|
|
137
|
+
self
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Ends the live stream, emitting any case whose glyphs are still buffered.
|
|
141
|
+
def flush!
|
|
142
|
+
return self unless streaming?
|
|
143
|
+
|
|
144
|
+
close_stream_line
|
|
145
|
+
pending_case_names.each { |name| open_stream_line(name) && close_stream_line }
|
|
146
|
+
writeln
|
|
147
|
+
reset_stream!
|
|
148
|
+
self
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Prints the summary. Returns the process exit status so a caller can
|
|
152
|
+
# `exit reporter.finish(...)` in one line.
|
|
153
|
+
def finish(results:, duration: 0.0, seed: nil, coverage: nil, suggestions: [], warnings: nil)
|
|
154
|
+
results = Array(results)
|
|
155
|
+
@seed = seed if seed
|
|
156
|
+
flush!
|
|
157
|
+
|
|
158
|
+
counts = tally(results)
|
|
159
|
+
@failed_live = counts[:failed]
|
|
160
|
+
warnings = normalize_warnings(warnings)
|
|
161
|
+
@warning_count = warnings.size
|
|
162
|
+
coverage = normalize_coverage(coverage)
|
|
163
|
+
@finished = true
|
|
164
|
+
|
|
165
|
+
writeln(paint(HEAVY_RULE, :dim))
|
|
166
|
+
writeln(header_line(results, duration))
|
|
167
|
+
writeln(paint(HEAVY_RULE, :dim))
|
|
168
|
+
writeln(headline(counts, warnings.size, coverage))
|
|
169
|
+
|
|
170
|
+
section_parole_violations(results)
|
|
171
|
+
section_failures(results)
|
|
172
|
+
section_warnings(warnings)
|
|
173
|
+
section_slowest(results)
|
|
174
|
+
rename_suggestions(suggestions)
|
|
175
|
+
|
|
176
|
+
writeln(paint(HEAVY_RULE, :dim))
|
|
177
|
+
exit_status
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# --- queries -------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
# 0 clean, 1 something the build should care about. Jailed tests, warrants and
|
|
183
|
+
# warnings are deliberately non-blocking -- unless a CI run opted into
|
|
184
|
+
# fail_on_warnings, which is the whole point of that setting.
|
|
185
|
+
def exit_status
|
|
186
|
+
return 1 if @failed_live.positive?
|
|
187
|
+
return 1 if @config.fail_on_warnings? && @warning_count.positive?
|
|
188
|
+
|
|
189
|
+
0
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def success? = exit_status.zero?
|
|
193
|
+
def failed? = !success?
|
|
194
|
+
def color? = @color
|
|
195
|
+
def finished? = @finished
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
# --- live glyph stream ---------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def reset_stream!
|
|
202
|
+
@stream_case = nil
|
|
203
|
+
@stream_pending = {}
|
|
204
|
+
@stream_open = false
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def streaming? = @stream_open || @stream_pending.any?
|
|
208
|
+
|
|
209
|
+
def pending_case_names
|
|
210
|
+
@stream_pending.reject { |_name, glyphs| glyphs.empty? }.keys
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def stream(result)
|
|
214
|
+
name = result.case_name.to_s
|
|
215
|
+
name = "(anonymous)" if name.empty?
|
|
216
|
+
glyph = paint(result.glyph, COLORS[result.status])
|
|
217
|
+
|
|
218
|
+
if @stream_open && @stream_case == name
|
|
219
|
+
write(glyph)
|
|
220
|
+
return
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
(@stream_pending[name] ||= +"") << glyph
|
|
224
|
+
return open_stream_line(name) unless @stream_open
|
|
225
|
+
|
|
226
|
+
promote_stream_line if buffered_glyph_count >= STREAM_FLUSH_THRESHOLD
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Glyphs waiting behind the case that currently owns the line.
|
|
230
|
+
def buffered_glyph_count
|
|
231
|
+
@stream_pending.sum { |_name, glyphs| count_glyphs(glyphs) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Ends the current line and hands it to whichever case has waited longest with
|
|
235
|
+
# the most to say. A case that has already had a line simply gets another one --
|
|
236
|
+
# repeating the name is honest, silently appending to a stale line is not.
|
|
237
|
+
def promote_stream_line
|
|
238
|
+
close_stream_line
|
|
239
|
+
busiest = @stream_pending.max_by { |_name, glyphs| count_glyphs(glyphs) }
|
|
240
|
+
open_stream_line(busiest.first) if busiest
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def open_stream_line(name)
|
|
244
|
+
glyphs = @stream_pending.delete(name).to_s
|
|
245
|
+
return false if glyphs.empty?
|
|
246
|
+
|
|
247
|
+
write(paint(pad(name, STREAM_NAME_WIDTH - STREAM_GAP), :dim))
|
|
248
|
+
write(" " * STREAM_GAP)
|
|
249
|
+
write(glyphs)
|
|
250
|
+
@stream_case = name
|
|
251
|
+
@stream_open = true
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def close_stream_line
|
|
255
|
+
return false unless @stream_open
|
|
256
|
+
|
|
257
|
+
writeln
|
|
258
|
+
@stream_case = nil
|
|
259
|
+
@stream_open = false
|
|
260
|
+
true
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Glyphs may carry escape codes; count only the visible ones.
|
|
264
|
+
def count_glyphs(string) = strip_ansi(string).length
|
|
265
|
+
|
|
266
|
+
# --- header and headline -------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def header_line(results, duration)
|
|
269
|
+
stats = [
|
|
270
|
+
"#{results.size} #{pluralize(results.size, "test")}",
|
|
271
|
+
"#{case_count(results)} #{pluralize(case_count(results), "case")}",
|
|
272
|
+
format_duration(duration)
|
|
273
|
+
].join(" · ")
|
|
274
|
+
|
|
275
|
+
INDENT + paint(pad(LABEL, LABEL_WIDTH), :bold) + stats
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def case_count(results) = results.map(&:case_name).uniq.size
|
|
279
|
+
|
|
280
|
+
# Zero-valued categories are omitted rather than printed as 0 -- except passed and
|
|
281
|
+
# failed, which are the two numbers a reader looks for first and so always show.
|
|
282
|
+
def headline(counts, warning_count, coverage)
|
|
283
|
+
parts = []
|
|
284
|
+
parts << count_part(:passed, counts[:passed], "passed", always: true)
|
|
285
|
+
parts << count_part(:failed, counts[:failed], "failed", always: true)
|
|
286
|
+
parts << jailed_part(counts)
|
|
287
|
+
parts << count_part(:skipped, counts[:skipped], "skipped")
|
|
288
|
+
parts << count_part(:parole, counts[:on_parole], "on parole")
|
|
289
|
+
parts << warrant_part(counts[:warranted])
|
|
290
|
+
parts << count_part(:warning, warning_count, "warning", plural: true)
|
|
291
|
+
parts << coverage_part(coverage)
|
|
292
|
+
|
|
293
|
+
INDENT + parts.compact.join(" ")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def count_part(key, count, label, always: false, plural: false)
|
|
297
|
+
count = count.to_i
|
|
298
|
+
return nil if count.zero? && !always
|
|
299
|
+
|
|
300
|
+
label = pluralize(count, label) if plural
|
|
301
|
+
paint("#{GLYPHS[key]} #{count} #{label}", COLORS[key])
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Parole violations are more urgent news than a plain jailing -- somebody deliberately
|
|
305
|
+
# trusted this test again -- so the headline splits them back out of the total.
|
|
306
|
+
def jailed_part(counts)
|
|
307
|
+
jailed = counts[:jailed].to_i
|
|
308
|
+
return nil if jailed.zero?
|
|
309
|
+
|
|
310
|
+
violations = counts[:parole_violations].to_i
|
|
311
|
+
text = "#{GLYPHS[:jailed]} #{jailed} jailed"
|
|
312
|
+
text += " (#{violations} #{pluralize(violations, "parole violation")})" if violations.positive?
|
|
313
|
+
paint(text, COLORS[:jailed])
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def warrant_part(count)
|
|
317
|
+
count = count.to_i
|
|
318
|
+
return nil if count.zero?
|
|
319
|
+
|
|
320
|
+
paint("#{GLYPHS[:warrant]} #{count} #{pluralize(count, "warrant")} issued", COLORS[:warranted])
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def coverage_part(coverage)
|
|
324
|
+
return nil if coverage.nil?
|
|
325
|
+
|
|
326
|
+
text = "#{GLYPHS[:coverage]} #{format_percent(coverage[:percent])}% covered"
|
|
327
|
+
# A 0% file is usually a missed file, not a thin one, so it gets named out loud.
|
|
328
|
+
if coverage[:unpatrolled].to_i.positive?
|
|
329
|
+
text += " (#{coverage[:unpatrolled]} #{pluralize(coverage[:unpatrolled], "file")} unpatrolled)"
|
|
330
|
+
end
|
|
331
|
+
paint(text, COLORS[:coverage])
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def tally(results)
|
|
335
|
+
{
|
|
336
|
+
passed: results.count(&:passed?),
|
|
337
|
+
failed: results.count(&:failed?),
|
|
338
|
+
jailed: results.count(&:jailed?),
|
|
339
|
+
parole_violations: results.count(&:parole_violation?),
|
|
340
|
+
on_parole: results.count { |r| r.parole_day && !r.parole_violation? },
|
|
341
|
+
warranted: results.count(&:warranted?),
|
|
342
|
+
skipped: results.count(&:skipped?)
|
|
343
|
+
}
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# --- sections ------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def section(title)
|
|
349
|
+
writeln
|
|
350
|
+
writeln(INDENT + paint(title, :bold))
|
|
351
|
+
writeln(INDENT + paint("─" * (UNDERLINE_WIDTHS[title] || title.length), :dim))
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def section_parole_violations(results)
|
|
355
|
+
violations = results.select(&:parole_violation?)
|
|
356
|
+
return if violations.empty?
|
|
357
|
+
|
|
358
|
+
section("PAROLE VIOLATED")
|
|
359
|
+
each_entry(violations) do |result|
|
|
360
|
+
writeln(INDENT + paint("#{GLYPHS[:parole_violation]} #{result.case_name}", COLORS[:parole_violation]))
|
|
361
|
+
writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
|
|
362
|
+
writeln(ENTRY_INDENT + parole_violation_sentence(result))
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def parole_violation_sentence(result)
|
|
367
|
+
sentence = "Failed on day #{result.parole_day} of a #{@config.parole_period}-run parole — back to jail."
|
|
368
|
+
sentence << " This is its #{ordinalize(result.times_jailed)} time in jail." if result.times_jailed
|
|
369
|
+
sentence
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def section_failures(results)
|
|
373
|
+
failures = results.select(&:failed?)
|
|
374
|
+
return if failures.empty?
|
|
375
|
+
|
|
376
|
+
section("FAILURES")
|
|
377
|
+
each_entry(failures) { |result| failure_entry(result) }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def failure_entry(result)
|
|
381
|
+
writeln(INDENT + paint("#{GLYPHS[:failed]} #{result.case_name}", COLORS[:failed]))
|
|
382
|
+
writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
|
|
383
|
+
writeln(ENTRY_INDENT + paint(result.location, :dim))
|
|
384
|
+
writeln
|
|
385
|
+
failure_message(result).each_line { |line| writeln(ENTRY_INDENT + line.chomp) }
|
|
386
|
+
failure_context(result)
|
|
387
|
+
writeln
|
|
388
|
+
writeln(ENTRY_INDENT + paint("Rerun just this test:", :dim))
|
|
389
|
+
writeln(DETAIL_INDENT + result.rerun_command)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def failure_message(result)
|
|
393
|
+
failure = result.failure
|
|
394
|
+
return "(no failure message recorded)" if failure.nil?
|
|
395
|
+
|
|
396
|
+
message = failure.message.to_s
|
|
397
|
+
message = "#{failure.exception_class}: #{message}" if result.status == :errored && failure.exception_class
|
|
398
|
+
message.empty? ? "(no failure message recorded)" : message
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# The matcher's own context -- a response body, a record's attributes. Printed
|
|
402
|
+
# verbatim, keeping whatever relative indentation the matcher chose.
|
|
403
|
+
def failure_context(result)
|
|
404
|
+
context = result.failure&.context
|
|
405
|
+
return if context.nil?
|
|
406
|
+
|
|
407
|
+
lines = context_lines(context)
|
|
408
|
+
return if lines.empty?
|
|
409
|
+
|
|
410
|
+
writeln
|
|
411
|
+
lines.each { |line| writeln(line.empty? ? "" : ENTRY_INDENT + line) }
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def context_lines(context)
|
|
415
|
+
lines = case context
|
|
416
|
+
when String then context.rstrip.lines.map { |line| line.chomp.rstrip }
|
|
417
|
+
when Hash then context.map { |key, value| "#{key}: #{value}" }
|
|
418
|
+
when Array then context.map(&:to_s)
|
|
419
|
+
else [context.to_s]
|
|
420
|
+
end
|
|
421
|
+
lines.all?(&:empty?) ? [] : lines
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def section_warnings(warnings)
|
|
425
|
+
return if warnings.empty?
|
|
426
|
+
|
|
427
|
+
section("WARNINGS")
|
|
428
|
+
each_entry(warnings) do |warning|
|
|
429
|
+
location = warning[:location].to_s
|
|
430
|
+
message = warning[:message].to_s
|
|
431
|
+
if location.empty?
|
|
432
|
+
writeln(INDENT + paint("#{GLYPHS[:warning]} #{message}", COLORS[:warning]))
|
|
433
|
+
else
|
|
434
|
+
writeln(INDENT + paint("#{GLYPHS[:warning]} #{location}", COLORS[:warning]))
|
|
435
|
+
message.each_line { |line| writeln(ENTRY_INDENT + line.chomp) }
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def section_slowest(results)
|
|
441
|
+
# A jailed test never ran its body, so it has no honest duration. A parole
|
|
442
|
+
# violation did run -- and failing slowly is still worth seeing.
|
|
443
|
+
timed = results.reject { |r| r.skipped? || r.status == :jailed }
|
|
444
|
+
.select { |r| r.duration.to_f.positive? }
|
|
445
|
+
.sort_by { |r| -r.duration.to_f }
|
|
446
|
+
.first(@slowest)
|
|
447
|
+
return if timed.empty?
|
|
448
|
+
|
|
449
|
+
section("SLOWEST")
|
|
450
|
+
width = timed.map { |r| format_duration(r.duration).length }.max
|
|
451
|
+
timed.each do |result|
|
|
452
|
+
stamp = format_duration(result.duration).rjust(width)
|
|
453
|
+
writeln("#{INDENT}#{paint(stamp, :dim)} #{result.case_name} #{paint(%("#{result.description}"), :dim)}")
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Rename detection is the Runner's job; the reporter only says it out loud. Never
|
|
458
|
+
# its own section -- it is a suggestion, not a finding.
|
|
459
|
+
def rename_suggestions(suggestions)
|
|
460
|
+
suggestions = Array(suggestions).map { |s| suggestion_line(s) }.compact
|
|
461
|
+
return if suggestions.empty?
|
|
462
|
+
|
|
463
|
+
writeln
|
|
464
|
+
suggestions.each { |line| writeln(INDENT + paint(line, :dim)) }
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def suggestion_line(suggestion)
|
|
468
|
+
return suggestion.to_s if suggestion.is_a?(String)
|
|
469
|
+
return nil unless suggestion.respond_to?(:to_h)
|
|
470
|
+
|
|
471
|
+
s = suggestion.to_h.transform_keys(&:to_sym)
|
|
472
|
+
from = s[:old_label] || s[:from] || label_for(s[:old_case], s[:old_description])
|
|
473
|
+
to = s[:new_label] || s[:to] || label_for(s[:new_case], s[:new_description])
|
|
474
|
+
old_hash = s[:old_hash] || s[:old_identity]
|
|
475
|
+
new_hash = s[:new_hash] || s[:new_identity]
|
|
476
|
+
return nil if from.nil? || to.nil?
|
|
477
|
+
|
|
478
|
+
"possible rename: #{from} → #{to}, " \
|
|
479
|
+
"run constable history relink #{old_hash} #{new_hash} to confirm"
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def label_for(case_name, description)
|
|
483
|
+
return nil if case_name.nil? && description.nil?
|
|
484
|
+
|
|
485
|
+
"#{case_name}##{description}"
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def each_entry(entries)
|
|
489
|
+
entries.each_with_index do |entry, index|
|
|
490
|
+
writeln if index.positive?
|
|
491
|
+
yield entry
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# --- warnings and coverage input -----------------------------------------------
|
|
496
|
+
|
|
497
|
+
# Warnings arrive from two directions: Constable.warn! in this process, and results
|
|
498
|
+
# shipped back by workers that warned in theirs. Same warning from four workers is
|
|
499
|
+
# still one warning, so they collapse on message+location.
|
|
500
|
+
def normalize_warnings(explicit)
|
|
501
|
+
raw = explicit || Constable.warnings
|
|
502
|
+
Array(raw).filter_map { |w| normalize_warning(w) }
|
|
503
|
+
.uniq { |w| [w[:message], w[:location]] }
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def normalize_warning(warning)
|
|
507
|
+
return { message: warning.to_s, location: nil, kind: :unsafe } if warning.is_a?(String)
|
|
508
|
+
return nil unless warning.respond_to?(:to_h)
|
|
509
|
+
|
|
510
|
+
w = warning.to_h.transform_keys(&:to_sym)
|
|
511
|
+
return nil if w[:message].nil?
|
|
512
|
+
|
|
513
|
+
{ message: w[:message].to_s, location: w[:location], kind: (w[:kind] || :unsafe).to_sym }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def normalize_coverage(coverage)
|
|
517
|
+
return nil if coverage.nil?
|
|
518
|
+
return { percent: coverage.to_f, unpatrolled: 0 } if coverage.is_a?(Numeric)
|
|
519
|
+
|
|
520
|
+
c = coverage.respond_to?(:to_h) ? coverage.to_h.transform_keys(&:to_sym) : {}
|
|
521
|
+
c = coverage_from_object(coverage) if c.empty?
|
|
522
|
+
percent = c[:percent] || c[:percentage] || c[:covered]
|
|
523
|
+
return nil if percent.nil?
|
|
524
|
+
|
|
525
|
+
{ percent: percent.to_f, unpatrolled: unpatrolled_count(c) }
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def coverage_from_object(coverage)
|
|
529
|
+
{
|
|
530
|
+
percent: (coverage.percent if coverage.respond_to?(:percent)),
|
|
531
|
+
unpatrolled: (coverage.unpatrolled if coverage.respond_to?(:unpatrolled))
|
|
532
|
+
}
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def unpatrolled_count(hash)
|
|
536
|
+
value = hash[:unpatrolled] || hash[:unpatrolled_files] || hash[:unpatrolled_count] || 0
|
|
537
|
+
value.is_a?(Integer) ? value : Array(value).size
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# --- formatting ----------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
# 1st, 2nd, 3rd, 4th -- and 11th/12th/13th, which is where naive versions break.
|
|
543
|
+
def ordinalize(number)
|
|
544
|
+
n = number.to_i
|
|
545
|
+
suffix = if (11..13).cover?(n.abs % 100)
|
|
546
|
+
"th"
|
|
547
|
+
else
|
|
548
|
+
{ 1 => "st", 2 => "nd", 3 => "rd" }.fetch(n.abs % 10, "th")
|
|
549
|
+
end
|
|
550
|
+
"#{n}#{suffix}"
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def pluralize(count, word)
|
|
554
|
+
count.to_i == 1 ? word : "#{word}s"
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def format_duration(seconds)
|
|
558
|
+
seconds = seconds.to_f
|
|
559
|
+
return format("%.1fs", seconds) if seconds < 60
|
|
560
|
+
|
|
561
|
+
minutes, rest = seconds.divmod(60)
|
|
562
|
+
format("%dm %.1fs", minutes, rest)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def format_percent(percent)
|
|
566
|
+
rounded = percent.round(1)
|
|
567
|
+
rounded == rounded.round ? rounded.round.to_s : format("%.1f", rounded)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# --- colour --------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
# Colour is a nicety; correctness is not. NO_COLOR, a pipe, a dumb terminal or an
|
|
573
|
+
# explicit --no-color all fall back to plain text with identical layout.
|
|
574
|
+
def resolve_color(color)
|
|
575
|
+
return !!color unless color.nil?
|
|
576
|
+
return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
|
|
577
|
+
return false if ENV["TERM"].to_s == "dumb"
|
|
578
|
+
return false unless @io.respond_to?(:tty?) && @io.tty?
|
|
579
|
+
|
|
580
|
+
true
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def paint(text, *styles)
|
|
584
|
+
styles = styles.flatten.compact
|
|
585
|
+
return text if !@color || styles.empty?
|
|
586
|
+
|
|
587
|
+
codes = styles.filter_map { |style| STYLES[style] }
|
|
588
|
+
return text if codes.empty?
|
|
589
|
+
|
|
590
|
+
"\e[#{codes.join(";")}m#{text}\e[0m"
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def strip_ansi(text) = text.to_s.gsub(/\e\[[0-9;]*m/, "")
|
|
594
|
+
|
|
595
|
+
# --- width-aware padding -------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
def display_width(string)
|
|
598
|
+
strip_ansi(string).each_char.sum { |char| char_width(char) }
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def char_width(char)
|
|
602
|
+
return 0 if char.match?(/\p{Mn}|\p{Me}|\p{Cf}/)
|
|
603
|
+
|
|
604
|
+
code = char.ord
|
|
605
|
+
WIDE_RANGES.any? { |range| range.cover?(code) } ? 2 : 1
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def pad(string, width)
|
|
609
|
+
padding = width - display_width(string)
|
|
610
|
+
padding.positive? ? string + (" " * padding) : string
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# --- output --------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
def write(text)
|
|
616
|
+
@io.write(text)
|
|
617
|
+
@io.flush if @io.respond_to?(:flush)
|
|
618
|
+
text
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def writeln(text = "")
|
|
622
|
+
write("#{text}\n")
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
end
|